diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 9d866e3..527d57b 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,7 +5,12 @@
 
 version: 2
 updates:
-  - package-ecosystem: "pip" # See documentation for possible values
-    directory: "/" # Location of package manifests
-    schedule:
-      interval: "weekly"
+- package-ecosystem: pip
+  directory: "/"
+  schedule:
+    interval: daily
+    time: "07:00"
+  groups:
+    python-packages:
+      patterns:
+        - "*"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..dabfd80
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,74 @@
+name: Publish to PyPI
+
+on:
+  push:
+    tags:
+      - '[0-9]+.[0-9]+.[0-9]+' # Matches semantic versioning tags
+      - '[0-9]+.[0-9]+.[0-9]+-test.*' # Test releases
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ['3.12']
+
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies and run CI
+        run: |
+          python -m pip install --upgrade pip
+          pip install build pytest pytest-cov setuptools_scm
+          make ci
+
+  publish:
+    needs: test
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: '3.12'
+
+      - name: Verify tag version matches package version
+        run: |
+          python -m pip install --upgrade pip
+          pip install build pytest pytest-cov setuptools_scm twine
+          PACKAGE_VERSION=$(python -m setuptools_scm)
+          TAG_VERSION=${GITHUB_REF#refs/tags/}  # Remove 'refs/tags/' prefix
+          if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
+            echo "Package version ($PACKAGE_VERSION) does not match tag version ($TAG_VERSION)"
+            exit 1
+          fi
+
+      - name: Publish to TestPyPI
+        if: contains(github.ref, 'test')
+        env:
+          TWINE_USERNAME: __token__
+          TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }}
+        run: make all && twine upload --repository testpypi dist/*
+
+      - name: Publish to PyPI
+        if: "!contains(github.ref, 'test')"
+        env:
+          TWINE_USERNAME: __token__
+          TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
+        run: make dist
+
+      - name: Create Release
+        uses: softprops/action-gh-release@v1
+        with:
+          files: dist/*
+          generate_release_notes: true
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..94aacfa
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,26 @@
+name: Tests
+
+on:
+  pull_request:
+    branches: [ main ]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ['3.12']
+
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies and run tests
+        run: |
+          python -m pip install --upgrade pip
+          pip install pytest pytest-cov
+          make ci
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8c6ae29
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+/src/version/_version.py
+src/*.egg-info
+__pycache__
+dist
+build
+out
+.venv
+.env
+.coverage
+.*.history*
+*.tags.cache*
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/LICENSE b/LICENSE
index e69de29..261eeb9 100644
--- a/LICENSE
+++ b/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b6fba03
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,25 @@
+.PHONY: all dist d clean c version v install i test t build b
+
+ci: clean install test
+all: ci build version
+
+dist d: all
+	scripts/check-version.sh
+	twine upload dist/*
+
+clean c:
+	rm -rfv out dist build/bdist.*
+
+version v:
+	git describe --tags ||:
+	python -m setuptools_scm
+
+install i:
+	pip install --upgrade --force-reinstall -e .
+
+test t:
+	pytest --cov=src/cedarscript_editor --cov=src/text_manipulation tests/ --cov-report term-missing
+
+build b:
+	# SETUPTOOLS_SCM_PRETEND_VERSION=0.0.1
+	python -m build
diff --git a/README.md b/README.md
index dce04c1..cda89bc 100644
--- a/README.md
+++ b/README.md
@@ -3,21 +3,33 @@
 [![PyPI version](https://badge.fury.io/py/cedarscript-editor.svg)](https://pypi.org/project/cedarscript-editor/)
 [![Python Versions](https://img.shields.io/pypi/pyversions/cedarscript-editor.svg)](https://pypi.org/project/cedarscript-editor/)
 [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
-[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
+[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
 
-`CEDARScript Editor (Python)` is a Python library for interpreting `CEDARScript` scripts and 
-performing code analysis and modification operations on a codebase.
+`CEDARScript Editor (Python)` is a [CEDARScript](https://bit.ly/cedarscript) runtime
+for interpreting `CEDARScript` scripts and performing code analysis and modification operations on a codebase.
+
+CEDARScript enables offloading _low-level code syntax and structure concerns_, such as indentation and line counting,
+from the LLMs.
+The CEDARScript runtime _bears the brunt of file editing_ by locating the exact line numbers and characters to change,
+which indentation levels to apply to each line and so on, allowing the _CEDARScript commands_ to focus instead on 
+**higher levels of abstraction**, like identifier names, line markers, relative indentations and positions
+(`AFTER`, `BEFORE`, `INSIDE` a function, its `BODY`, at the `TOP` or `BOTTOM` of it...).
+
+It acts as an _intermediary_ between the **LLM** and the **codebase**, handling the low-level details of code
+manipulation and allowing the AI to focus on higher-level tasks.
 
 ## What is CEDARScript?
 
 [CEDARScript](https://github.com/CEDARScript/cedarscript-grammar#readme) (_Concise Examination, Development, And Refactoring Script_)
-is a domain-specific language that aims to improve how AI coding assistants interact with codebases and communicate their code modification intentions.
+is a domain-specific language that aims to improve how AI coding assistants interact with codebases and communicate
+their code modification intentions.
+
 It provides a standardized way to express complex code modification and analysis operations, making it easier for
 AI-assisted development tools to understand and execute these tasks.
 
 ## Features
 
-- Given a `CEDARScript` script and a base direcotry, executes the script commands on files inside the base directory;
+- Given a `CEDARScript` script and a base directory, executes the script commands on files inside the base directory;
 - Return results in `XML` format for easier parsing and processing by LLM systems
 
 ## Installation
@@ -25,7 +37,7 @@ AI-assisted development tools to understand and execute these tasks.
 You can install `CEDARScript` Editor using pip:
 
 ```
-pip install cedarscript_editor
+pip install cedarscript-editor
 ```
 
 ## Usage
diff --git a/pyproject.toml b/pyproject.toml
index 4d629ee..da22f44 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,19 +1,18 @@
 [build-system]
-requires = ["setuptools>=61.0", "wheel"]
+requires = ["setuptools>=61.0", "wheel", "setuptools_scm[toml]>=6.2"]
 build-backend = "setuptools.build_meta"
 
 [project]
 name = "cedarscript-editor"
 dynamic = ["version"]
 description = "A library for executing CEDARScript, a SQL-like language for code analysis and transformations"
+authors = [{ name = "Elifarley", email = "cedarscript@orgecc.com" }]
 readme = "README.md"
-authors = [{ name = "Elifarley", email = "elifarley@example.com" }]
-license = { file = "LICENSE" }
+license = {text = "Apache-2.0"}
 classifiers = [
-    "Development Status :: 3 - Alpha",
     "Programming Language :: Python :: 3",
     "Programming Language :: Python :: 3.12",
-    "License :: OSI Approved :: MIT License",
+    "License :: OSI Approved :: Apache Software License",
     "Operating System :: OS Independent",
     "Intended Audience :: Developers",
     "Topic :: Software Development :: Libraries :: Python Modules",
@@ -23,10 +22,15 @@ classifiers = [
 ]
 keywords = ["cedarscript", "code-editing", "refactoring", "code-analysis", "sql-like", "ai-assisted-development"]
 dependencies = [
-    "cedarscript-ast-parser>=0.1.6",
-    "rope>=1.13.0"
+    "orgecc-pylib>=0.1.3",
+    "cedarscript-ast-parser>=0.7.0",
+    "grep-ast==0.4.1",
+    # https://github.com/tree-sitter/py-tree-sitter/issues/303
+    # https://github.com/grantjenks/py-tree-sitter-languages/issues/64
+    "tree-sitter==0.21.3", # 0.22 breaks tree-sitter-languages
+    "tree-sitter-languages==1.10.2",
 ]
-requires-python = ">=3.8"
+requires-python = ">=3.11"
 
 [project.urls]
 Homepage = "https://github.com/CEDARScript/cedarscript-editor-python"
@@ -37,6 +41,7 @@ Repository = "https://github.com/CEDARScript/cedarscript-editor-python.git"
 [project.optional-dependencies]
 dev = [
     "pytest>=7.0",
+    "pytest-cov>=6.0.0",
     "black>=22.0",
     "isort>=5.0",
     "flake8>=4.0",
@@ -47,15 +52,20 @@ dev = [
 
 [tool.setuptools]
 package-dir = {"" = "src"}
-py-modules = ["cedarscript_editor", "text_manipulaiton"]
 
-[tool.setuptools.dynamic]
-version = {attr = "cedarscript_editor.__version__"}
+[tool.setuptools_scm]
+# To override version:
+# >>> SETUPTOOLS_SCM_PRETEND_VERSION=0.0.2 python -m build
+# To dry-run and see version:
+# >>> python -m setuptools_scm
+write_to = "src/version/_version.py"
+# Append .post{number of commits} to your version if there are commits after the last tag.
+version_scheme = "post-release"
 
 [tool.setuptools.packages.find]
 where = ["src"]
-include = ["cedarscript_editor*", "text_editor*", "identifier_selector*", "*identifier_finder*",
-"indentation_*", "range_*"]
+include = ["version", "tree-sitter-queries", "cedarscript_editor*", "text_manipulation*"]
+exclude = ["cedarscript_ast_parser.tests*"]
 namespaces = false
 
 [tool.setuptools.package-data]
@@ -63,15 +73,17 @@ namespaces = false
 
 [tool.black]
 line-length = 100
-target-version = ['py312']
+target-version = ['py311']
 
 [tool.isort]
 profile = "black"
 line_length = 100
 
 [tool.mypy]
-ignore_missing_imports = true
+python_version = "3.11"
 strict = true
+warn_return_any = true
+warn_unused_configs = true
 
 [tool.pytest.ini_options]
 minversion = "6.0"
diff --git a/scripts/check-version.sh b/scripts/check-version.sh
new file mode 100755
index 0000000..a6a4293
--- /dev/null
+++ b/scripts/check-version.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env sh
+version=$(python -m setuptools_scm)
+# Check if the version is pure (i.e., it doesn't contain a '+')
+echo "$version" | grep -q "+" && {
+  echo "Error: Version '$version' is not pure. Aborting dist."
+  exit 1
+}
+exit 0
diff --git a/src/cedarscript_editor/__init__.py b/src/cedarscript_editor/__init__.py
index 6f0f36e..c7b0617 100644
--- a/src/cedarscript_editor/__init__.py
+++ b/src/cedarscript_editor/__init__.py
@@ -1,6 +1,29 @@
-from cedarscript_editor.cedarscript_editor import CEDARScriptEditor
+from version import __version__
+import re
+from .cedarscript_editor import CEDARScriptEditor
+from cedarscript_ast_parser import CEDARScriptASTParser
 
-__version__ = "0.2.0"
+__all__ = [
+    "__version__", "find_commands", "CEDARScriptEditor"
+]
 
-__all__ = ["CEDARScriptEditor"]
 
+# TODO Move to cedarscript-ast-parser
+def find_commands(content: str):
+    # Regex pattern to match CEDARScript blocks
+    pattern = r'```CEDARScript\n(.*?)```'
+    cedar_script_blocks = re.findall(pattern, content, re.DOTALL)
+    print(f'[find_cedar_commands] Script block count: {len(cedar_script_blocks)}')
+    if len(cedar_script_blocks) == 0 and not content.strip().endswith('<NOCEDARSCRIPT/>'):
+        raise ValueError(
+            "No CEDARScript block detected. "
+            "Perhaps you forgot to enclose the block using ```CEDARScript and ``` ? "
+            "Or was that intentional? If so, just write tag <NOCEDARSCRIPT/> as the last line"
+        )
+    cedarscript_parser = CEDARScriptASTParser()
+    for cedar_script in cedar_script_blocks:
+        parsed_commands, parse_errors = cedarscript_parser.parse_script(cedar_script)
+        if parse_errors:
+            raise ValueError(f"CEDARScript parsing errors: {[str(pe) for pe in parse_errors]}")
+        for cedar_command in parsed_commands:
+            yield cedar_command
diff --git a/src/cedarscript_editor/case_filter.py b/src/cedarscript_editor/case_filter.py
new file mode 100644
index 0000000..0d54fb2
--- /dev/null
+++ b/src/cedarscript_editor/case_filter.py
@@ -0,0 +1,119 @@
+from typing import Optional, Sequence
+from cedarscript_ast_parser import CaseStatement, CaseWhen, CaseAction, LoopControl
+
+
+# <dt>case_stmt: CASE WHEN (EMPTY | REGEX r"<string>" | PREFIX "<string>" | SUFFIX "<string>" | INDENT LEVEL <integer> | LINE NUMBER <integer> ) \
+# THEN (CONTINUE | BREAK | REMOVE [BREAK] | INDENT <integer> [BREAK] | REPLACE r"<string>" [BREAK] | <content_literal> [BREAK] | <content_from_segment> [BREAK])</dt>
+# <dd>This is the versatile `WHEN..THEN` content filter. Only used in conjunction with <replace_region_clause>. \
+# Filters each line of the region according to `WHEN/THEN` pairs:</dd>
+# <dd>WHEN: Allows you to choose which *matcher* to use:</dd>
+# <dd>EMPTY: Matches an empty line</dd>
+# <dd>REGEX: Regex matcher. Allows using capture groups in the `REPLACE` action</dd>
+# <dd>PREFIX: Matches by line prefix</dd>
+# <dd>SUFFIX: Matches by line suffix</dd>
+# <dd>INDENT LEVEL: Matches lines with specific indent level</dd>
+# <dd>LINE NUMBER: Matches by line number</dd>
+# <dd>THEN: Allows you to choose which *action* to take for its matched line:</dd>
+# <dd>CONTINUE: Leaves the line as is and goes to the next</dd>
+# <dd>BREAK: Stops processing the lines, leaving the rest of the lines untouched</dd>
+# <dd>REMOVE: Removes the line</dd>
+# <dd>INDENT: Increases or decreases indent level. Only positive or negative integers</dd>
+# <dd>REPLACE: Replace with text (regex capture groups enabled: \\1, \\2, etc)</dd>
+# <dd><content_literal> or <content_from_segment>: Replace with text (can't use regex capture groups)</dd>
+# <dt>
+
+
+def process_case_statement(content: Sequence[str], case_statement: CaseStatement) -> list[str]:
+    """Process content lines according to CASE statement rules.
+    
+    Args:
+        content: Sequence of strings to process
+        case_statement: CaseStatement containing when/then rules
+        
+    Returns:
+        List of processed strings
+    """
+    result = []
+    
+    for line_num, line in enumerate(content, start=1):
+        indent_level = (len(line) - len(line.lstrip())) // 4
+        matched = False
+        
+        # Process each when/then pair
+        for when, action in case_statement.cases:
+            if _matches_when(line, when, indent_level, line_num):
+                matched = True
+                processed = _apply_action(line, action, indent_level, when)
+                
+                if processed is None:  # REMOVE action
+                    break
+                if isinstance(processed, LoopControl):
+                    if processed == LoopControl.BREAK:
+                        result.append(line)
+                        result.extend(content[line_num:])
+                        return result
+                    elif processed == LoopControl.CONTINUE:
+                        result.append(line)
+                        break
+                else:
+                    result.append(processed)
+                break
+        
+        # If no when conditions matched, use else action if present
+        if not matched and case_statement.else_action is not None:
+            processed = _apply_action(line, case_statement.else_action, indent_level, None)
+            if processed is not None and not isinstance(processed, LoopControl):
+                result.append(processed)
+        elif not matched:
+            result.append(line)
+            
+    return result
+
+def _matches_when(line: str, when: CaseWhen, indent_level: int, line_num: int) -> bool:
+    """Check if a line matches the given when condition."""
+    stripped = line.strip()
+    if when.empty and not stripped:
+        return True
+    if when.regex and when.regex.search(stripped):
+        return True
+    if when.prefix and stripped.startswith(when.prefix.strip()):
+        return True
+    if when.suffix and stripped.endswith(when.suffix.strip()):
+        return True
+    if when.indent_level is not None and indent_level == when.indent_level:
+        return True
+    if when.line_matcher and stripped == when.line_matcher.strip():
+        return True
+    if when.line_number is not None and line_num == when.line_number:
+        return True
+    return False
+
+
+def _apply_action(line: str, action: CaseAction, current_indent: int, when: CaseWhen) -> Optional[str | LoopControl]:
+    """Apply the given action to a line.
+    
+    Returns:
+        - None for REMOVE action
+        - LoopControl enum for BREAK/CONTINUE
+        - Modified string for other actions
+    """
+    if action.loop_control:
+        return action.loop_control
+    if action.remove:
+        return None
+    if action.indent is not None:
+        new_indent = current_indent + action.indent
+        if new_indent < 0:
+            new_indent = 0
+        return " " * (new_indent * 4) + line.lstrip()
+    if action.sub_pattern is not None:
+        line = action.sub_pattern.sub(action.sub_repl, line)
+    if action.content is not None:
+        if isinstance(action.content, str):
+            # TODO
+            return " " * (current_indent * 4) + action.content
+        else:
+            region, indent = action.content
+            # TODO Handle region content replacement - would need region processing logic
+            return line
+    return line
diff --git a/src/cedarscript_editor/cedarscript_editor.py b/src/cedarscript_editor/cedarscript_editor.py
index 39eeebe..e58cf6b 100644
--- a/src/cedarscript_editor/cedarscript_editor.py
+++ b/src/cedarscript_editor/cedarscript_editor.py
@@ -1,17 +1,20 @@
 import os
 from collections.abc import Sequence
-from typing import Callable
+from pathlib import Path
 
 from cedarscript_ast_parser import Command, RmFileCommand, MvFileCommand, UpdateCommand, \
-    SelectCommand, IdentifierFromFile, Segment, Marker, MoveClause, DeleteClause, \
-    InsertClause, ReplaceClause, EditingAction, BodyOrWhole, RegionClause, MarkerType
+    SelectCommand, CreateCommand, IdentifierFromFile, Segment, Marker, MoveClause, DeleteClause, \
+    InsertClause, ReplaceClause, EditingAction, BodyOrWhole, RegionClause, MarkerType, EdScript, \
+    CaseStatement
+from .ed_script_filter import process_ed_script
+from .case_filter import process_case_statement
 from cedarscript_ast_parser.cedarscript_ast_parser import MarkerCompatible, RelativeMarker, \
-    RelativePositionType
-from text_manipulation.indentation_kit import IndentationInfo
-from text_manipulation.range_spec import IdentifierBoundaries, RangeSpec
-from text_manipulation.text_editor_kit import read_file, write_file, bow_to_search_range
+    RelativePositionType, Region, SingleFileClause
+from text_manipulation import (
+    IndentationInfo, IdentifierBoundaries, RangeSpec, read_file, write_file, bow_to_search_range, IdentifierFinder
+)
 
-from .identifier_selector import select_finder
+from .tree_sitter_identifier_finder import TreeSitterIdentifierFinder
 
 
 class CEDARScriptEditorException(Exception):
@@ -33,46 +36,47 @@ def __init__(self, command_ordinal: int, description: str):
 
             previous_cmd_notes = (
                 f", bearing in mind the file was updated and now contains all changes expressed in "
-                f"commands {items}"
+                f"commands {items}."
             )
             if 'syntax' in description.casefold():
                 probability_indicator = "most probably"
             else:
-                probability_indicator= "might have"
+                probability_indicator = "might have"
 
             note = (
-                f"<note>*ALL* commands *before* command #{command_ordinal} were applied and *their changes are already committed*. "
-                f"Re-read the file to catch up with the applied changes."
-                f"ATTENTION: The previous command (#{command_ordinal - 1}) {probability_indicator} caused command #{command_ordinal} to fail "
-                f"due to changes that left the file in an invalid state (check that by re-analyzing the file!)</note>"
+                f"<note>*ALL* commands *before* command #{command_ordinal} "
+                "were applied and *their changes are already committed*. "
+                f"So, it's *CRUCIAL* to re-analyze the file to catch up with the applied changes "
+                "and understand what still needs to be done. "
+                f"ATTENTION: The previous command (#{command_ordinal - 1}) {probability_indicator} "
+                f"caused command #{command_ordinal} to fail "
+                f"due to changes that left the file in an invalid state (check that by re-reading the file!)</note>"
             )
         super().__init__(
-            f"<error-details><error-location>COMMAND #{command_ordinal}</error-location>{note}"
-            f"<description>{description}</description>"
-            "<suggestion>NEVER apologize; just relax, take a deep breath, think step-by-step and write an in-depth analysis of what went wrong "
-            "(specifying which command ordinal failed), then acknowledge which commands were already applied and concisely describe the state at which the file was left "
-            "(saying what needs to be done now), "
-            f"then write new commands that will fix the problem{previous_cmd_notes} "
-            "(you'll get a one-million dollar tip if you get it right!) "
-            "Use descriptive comment before each command.</suggestion></error-details>"
+            "<error-details>"
+            f"\n<error-location>COMMAND #{command_ordinal}</error-location>"
+            f"\n<description>{description}</description>"
+            f"\n{note}"
+            "\n<suggestion>Reflect about common mistakes when using CEDARScript. Now relax, take a deep breath, "
+            "think step-by-step and write an in-depth analysis of what went wrong (specifying which command ordinal "
+            "failed), then acknowledge which commands were already applied and concisely describe the state at which "
+            "the file was left (saying what needs to be done now). Write all that inside these 2 tags "
+            "(<reasoning>...Chain of thoughts and reasoning here...</reasoning>\\n<verdict>...distilled analysis "
+            "here...</verdict>); "
+            "Then write new commands that will fix the problem"
+            f"{previous_cmd_notes} (you'll get a one-million dollar tip if you get it right!) "
+            "Use descriptive comment before each command; If showing CEDARScript commands to the user, "
+            "*DON'T* enclose them in ```CEDARSCript and ``` otherwise they will be executed!"
+            "</suggestion>\n</error-details>"
         )
 
 
 class CEDARScriptEditor:
-    def __init__(self, root_path):
-        self.root_path = os.path.abspath(root_path)
-        print(f'[{self.__class__}] root: {self.root_path}')
+    def __init__(self, root_path: os.PathLike):
+        self.root_path = Path(os.path.abspath(root_path))
+        print(f'[{self.__class__.__name__}] root: {self.root_path}')
 
     # TODO Add 'target_search_range: RangeSpec' parameter
-    def find_identifier(self, source_info: tuple[str, str | Sequence[str]], marker: Marker) -> IdentifierBoundaries:
-        file_path = source_info[0]
-        source = source_info[1]
-        if not isinstance(source, str):
-            source = '\n'.join(source)
-        return (
-            select_finder(self.root_path, file_path, source)
-                (self.root_path, file_path, source, marker)
-        )
 
     def apply_commands(self, commands: Sequence[Command]):
         result = []
@@ -81,20 +85,18 @@ def apply_commands(self, commands: Sequence[Command]):
                 match command:
                     case UpdateCommand() as cmd:
                         result.append(self._update_command(cmd))
-                    # case CreateCommand() as cmd:
-                    #     result.append(self._create_command(cmd))
+                    case CreateCommand() as cmd:
+                        result.append(self._create_command(cmd))
                     case RmFileCommand() as cmd:
                         result.append(self._rm_command(cmd))
-                    case MvFileCommand() as cmd:
+                    case MvFileCommand():
                         raise ValueError('Noy implemented: MV')
-                    case SelectCommand() as cmd:
+                    case SelectCommand():
                         raise ValueError('Noy implemented: SELECT')
                     case _ as invalid:
                         raise ValueError(f"Unknown command '{type(invalid)}'")
             except Exception as e:
                 print(f'[apply_commands] (command #{i+1}) Failed: {command}')
-                if isinstance(command, UpdateCommand):
-                    print(f'CMD CONTENT: ***{command.content}***')
                 raise CEDARScriptEditorException(i + 1, str(e)) from e
         return result
 
@@ -104,128 +106,110 @@ def _update_command(self, cmd: UpdateCommand):
         content = cmd.content or []
         file_path = os.path.join(self.root_path, target.file_path)
 
-        # Example 1:
-        # UPDATE FILE "tmp.benchmarks/2024-10-04-22-59-58--CEDARScript-Gemini-small/bowling/bowling.py"
-        # INSERT INSIDE FUNCTION "__init__" TOP
-        # WITH CONTENT '''
-        #  @0:print("This line will be inserted at the top")
-        #  ''';
-        # After parsing ->
-        # UpdateCommand(
-        #     type='update',
-        #     target=SingleFileClause(file_path='tmp.benchmarks/2024-10-04-22-59-58--CEDARScript-Gemini-small/bowling/bowling.py'),
-        #     action=InsertClause(insert_position=RelativeMarker(type=<MarkerType.FUNCTION: 'function'>, value='__init__', offset=None)),
-        #     content='\n @0:print("This line will be inserted at the top")\n '
-        # )
-
-
-        # Example 2:
-        # UPDATE FUNCTION
-        # FROM FILE "tmp.benchmarks/2024-10-04-22-59-58--CEDARScript-Gemini-small/bowling/bowling.py"
-        # WHERE NAME = "__init__"
-        # REPLACE SEGMENT
-        # STARTING AFTER LINE "def __init__(self):"
-        # ENDING AFTER LINE "def __init__(self):"
-        # WITH CONTENT '''
-        #  @0:print("This line will be inserted at the top")
-        #  ''';
-        # After parsing ->
-        # UpdateCommand(
-        # type='update',
-        # target=IdentifierFromFile(file_path='bowling.py',
-        # where_clause=WhereClause(field='NAME', operator='=', value='__init__'),
-        # identifier_type='FUNCTION', offset=None
-        # ),
-        # action=ReplaceClause(
-        # region=Segment(
-        # start=RelativeMarker(type=<MarkerType.LINE: 'line'>, value='def __init__(self):', offset=None),
-        # end=RelativeMarker(type=<MarkerType.LINE: 'line'>, value='def __init__(self):', offset=None)
-        # )),
-        # content='\n @0:print("This line will be inserted at the top")\n '
-        # )
-
         src = read_file(file_path)
         lines = src.splitlines()
 
-        source_info: tuple[str, str | Sequence[str]] = (file_path, src)
-
-        def identifier_resolver(m: Marker):
-            return self.find_identifier(source_info, m)
-
+        identifier_finder = TreeSitterIdentifierFinder(file_path, src, RangeSpec.EMPTY)
+            
+        search_range = RangeSpec.EMPTY
+        move_src_range = None
         match action:
             case MoveClause():
-                # (Check parse_update_command)
-                # when action=MoveClause example (MOVE roll TO AFTER score):
-                #   action.deleteclause.region=WHOLE
-                #   action.as_marker = action.insertclause.as_marker
-                #   action.insertclause.insert_position=FUNCTION(score)
-                #   target.as_marker = FUNCTION(roll) (the one to delete)
-                search_range = RangeSpec.EMPTY
-                move_src_range = restrict_search_range(action, target, identifier_resolver)
-            case _:
-                move_src_range = None
-                # Set range_spec to cover the identifier
-                search_range = restrict_search_range(action, target, identifier_resolver)
-
-        marker, search_range = find_marker_or_segment(action, lines, search_range)
+                # READ + DELETE region  : action.region (PARENT RESTRICTION: target.as_marker)
+                move_src_range = restrict_search_range(action.region, target, identifier_finder, lines)
+                # WRITE region: action.insert_position
+                search_range = restrict_search_range(action.insert_position, None, identifier_finder, lines)
+            case RegionClause(region=region) | InsertClause(insert_position=region):
+                search_range = restrict_search_range(region, target, identifier_finder, lines)
+
+        if search_range and search_range.line_count:
+            match action:
+                case RegionClause(region=Segment()):
+                    pass
+                case RegionClause(region=Marker()) if action.region.type in [MarkerType.FUNCTION, MarkerType.METHOD, MarkerType.CLASS]:
+                    pass
+                case _:
+                    marker, search_range = find_marker_or_segment(action, lines, search_range)
+                    match action:
+                        case InsertClause(insert_position=RelativeMarker(
+                            qualifier=qualifier)
+                        ):
+                            # TODO Handle BEFORE AFTER INSIDE_TOP INSIDE_BOTTOM
+                            search_range = search_range.set_line_count(0)
+                            if qualifier != RelativePositionType.AFTER:
+                                search_range = search_range.inc()
 
-        search_range = restrict_search_range_for_marker(
-            marker, action, lines, search_range, identifier_resolver
-        )
 
         match content:
+            case EdScript() as ed_script_filter:
+                # Filter the search range lines using an ED script
+                range_lines = search_range.read(lines)
+                content = process_ed_script(range_lines, ed_script_filter.script)
+            case CaseStatement() as case_filter:
+                # Filter the search range lines using `WHEN..THEN` pairs of a CASE statement
+                range_lines = search_range.read(lines)
+                content = process_case_statement(range_lines, case_filter)
             case str() | [str(), *_] | (str(), *_):
                 pass
-            case (region, relindent):
-                dest_indent = search_range.indent
+            case (region, relindent_level):
                 content_range = restrict_search_range_for_marker(
-                    region, action, lines, RangeSpec.EMPTY, identifier_resolver
+                    region, action, lines, RangeSpec.EMPTY, identifier_finder
                 )
-                content = content_range.read(lines)
-                count = dest_indent + (relindent or 0)
-                content = IndentationInfo.from_content(content).shift_indentation(
-                    content, count
+                content = IndentationInfo.shift_indentation(
+                    content_range.read(lines), lines, search_range.indent, relindent_level,
+                    identifier_finder
                 )
                 content = (region, content)
             case _:
                 match action:
-                    case MoveClause(insert_position=region, relative_indentation=relindent):
-                        dest_range = restrict_search_range_for_marker(
-                            region, action, lines, RangeSpec.EMPTY, identifier_resolver
-                        )
-                        dest_indent = dest_range.indent
-                        content = move_src_range.read(lines)
-                        count = dest_indent + (relindent or 0)
-                        content = IndentationInfo.from_content(content).shift_indentation(
-                            content, count
+                    case MoveClause(insert_position=region, relative_indentation=relindent_level):
+                        content = IndentationInfo.shift_indentation(
+                            move_src_range.read(lines), lines, search_range.indent, relindent_level,
+                            identifier_finder
                         )
+                    case DeleteClause():
+                        pass
                     case _:
                         raise ValueError(f'Invalid content: {content}')
 
-        self._apply_action(action, lines, search_range, content)
+        self._apply_action(
+            action, lines, search_range, content,
+            range_spec_to_delete=move_src_range, identifier_finder=identifier_finder
+        )
 
         write_file(file_path, lines)
 
-        return f"Updated {target if target else 'file'} in {file_path}\n  -> {action}"
+        return f"Updated {target if target else 'file'}\n  -> {action}"
 
-    def _apply_action(self, action: EditingAction, lines: Sequence[str], range_spec: RangeSpec, content: str | None = None):
+    @staticmethod
+    def _apply_action(
+        action: EditingAction, lines: Sequence[str], range_spec: RangeSpec, content: str | None = None,
+        range_spec_to_delete: RangeSpec | None = None,
+        identifier_finder: IdentifierFinder | None = None
+    ):
         match action:
 
             case MoveClause(insert_position=insert_position, to_other_file=other_file, relative_indentation=relindent):
                 # TODO Move from 'lines' to the same file or to 'other_file'
-                range_spec.write(content, lines)
+
+                if range_spec < range_spec_to_delete:
+                    range_spec_to_delete.delete(lines)
+                    range_spec.write(content, lines)
+                elif range_spec > range_spec_to_delete:
+                    range_spec.write(content, lines)
+                    range_spec_to_delete.delete(lines)
 
             case DeleteClause():
                 range_spec.delete(lines)
 
             case ReplaceClause() | InsertClause():
                 match content:
-                    case (region, processed_content):
-                        content = processed_content
                     case str():
-                        content = IndentationInfo.from_content(lines).apply_relative_indents(
+                        content = IndentationInfo.from_content(lines, identifier_finder).apply_relative_indents(
                             content, range_spec.indent
                         )
+                    case Sequence():
+                        content = [line.rstrip() for line in content]
 
                 range_spec.write(content, lines)
 
@@ -235,53 +219,74 @@ def _apply_action(self, action: EditingAction, lines: Sequence[str], range_spec:
     def _rm_command(self, cmd: RmFileCommand):
         file_path = os.path.join(self.root_path, cmd.file_path)
 
-    def _delete_function(self, cmd): # TODO
+    def _delete_function(self, cmd):  # TODO
         file_path = os.path.join(self.root_path, cmd.file_path)
 
-    # def _create_command(self, cmd: CreateCommand):
-    #     file_path = os.path.join(self.root_path, cmd.file_path)
-    #
-    #     os.makedirs(os.path.dirname(file_path), exist_ok=False)
-    #     with open(file_path, 'w') as file:
-    #         file.write(content)
-    #
-    #     return f"Created file: {command['file']}"
-
-    def find_index_range_for_region(self,
-                                    region: BodyOrWhole | Marker | Segment | RelativeMarker,
-                                    lines: Sequence[str],
-                                    identifier_resolver: Callable[[Marker], IdentifierBoundaries],
-                                    search_range: RangeSpec | IdentifierBoundaries | None = None,
-                                    ) -> RangeSpec:
-        # BodyOrWhole | RelativeMarker | MarkerOrSegment
-        # marker_or_segment_to_index_range_impl
-        # IdentifierBoundaries.location_to_search_range(self, location: BodyOrWhole | RelativePositionType) -> RangeSpec
-        match region:
-            case BodyOrWhole() as bow:
-                # TODO Set indent char count
-                index_range = bow_to_search_range(bow, search_range)
-            case Marker() | Segment() as mos:
-                if isinstance(search_range, IdentifierBoundaries):
-                    search_range = search_range.whole
-                match mos:
-                    case Marker(type=marker_type):
-                        match marker_type:
-                            case MarkerType.LINE:
-                                pass
-                            case _:
-                                # TODO transform to RangeSpec
-                                mos = self.find_identifier(("find_index_range_for_region", lines), mos).body
-                index_range = mos.to_search_range(
-                    lines,
-                    search_range.start if search_range else 0,
-                    search_range.end if search_range else -1,
-                )
-            case _ as invalid:
-                raise ValueError(f"Invalid: {invalid}")
-        return index_range
+    def _create_command(self, cmd: CreateCommand) -> str:
+        """Handle the CREATE command to create new files with content.
+        
+        Args:
+            cmd: The CreateCommand instance containing file_path and content
+            
+        Returns:
+            str: A message describing the result
+            
+        Raises:
+            ValueError: If the file already exists
+        """
+        file_path = os.path.join(self.root_path, cmd.file_path)
+        
+        if os.path.exists(file_path):
+            raise ValueError(f"File already exists: {cmd.file_path}")
+            
+        os.makedirs(os.path.dirname(file_path), exist_ok=True)
+        
+        content = cmd.content
+        if isinstance(content, (list, tuple)):
+            content = '\n'.join(content)
+            
+        # Process relative indentation in content
+        write_file(file_path, IndentationInfo.default().apply_relative_indents(content))
+        
+        return f"Created file: {cmd.file_path}"
+
+
+def find_index_range_for_region(region: BodyOrWhole | Marker | Segment | RelativeMarker,
+                                lines: Sequence[str],
+                                identifier_finder_IS_IT_USED: IdentifierFinder,
+                                search_range: RangeSpec | IdentifierBoundaries | None = None,
+                                ) -> RangeSpec:
+    # BodyOrWhole | RelativeMarker | MarkerOrSegment
+    # marker_or_segment_to_index_range_impl
+    # IdentifierBoundaries.location_to_search_range(self, location: BodyOrWhole | RelativePositionType) -> RangeSpec
+    match region:
+        case BodyOrWhole() as bow:
+            # TODO Set indent char count
+            index_range = bow_to_search_range(bow, search_range)
+        case Marker() | Segment() as mos:
+            if isinstance(search_range, IdentifierBoundaries):
+                search_range = search_range.whole
+            match mos:
+                case Marker(type=marker_type):
+                    match marker_type:
+                        case MarkerType.LINE:
+                            pass
+                        case _:
+                            # TODO transform to RangeSpec
+                            mos = TreeSitterIdentifierFinder("TODO?.py", lines, RangeSpec.EMPTY)(mos, search_range).body
+            index_range = mos.to_search_range(
+                lines,
+                search_range.start if search_range else 0,
+                search_range.end if search_range else -1,
+            )
+        case _ as invalid:
+            raise ValueError(f"Invalid: {invalid}")
+    return index_range
 
 
-def find_marker_or_segment(action: EditingAction, lines: Sequence[str], search_range: RangeSpec) -> tuple[Marker, RangeSpec]:
+def find_marker_or_segment(
+        action: EditingAction, lines: Sequence[str], search_range: RangeSpec
+) -> tuple[Marker, RangeSpec]:
     marker: Marker | Segment | None = None
     match action:
         case MarkerCompatible() as marker_compatible:
@@ -294,25 +299,49 @@ def find_marker_or_segment(action: EditingAction, lines: Sequence[str], search_r
                     # TODO Handle segment's start and end as a marker and support identifier markers
                     search_range = segment.to_search_range(lines, search_range)
                     marker = None
+                case BodyOrWhole():
+                    if search_range.end == -1:
+                        search_range = search_range._replace(end=len(lines))
+
     return marker, search_range
 
 
-def restrict_search_range(action, target, identifier_resolver: Callable[[Marker], IdentifierBoundaries]) -> RangeSpec:
-    search_range = RangeSpec.EMPTY
-    match target:
-        case IdentifierFromFile() as identifier_from_file:
-            identifier_marker = identifier_from_file.as_marker
-            identifier_boundaries = identifier_resolver(identifier_marker)
-            if not identifier_boundaries:
-                raise ValueError(f"'{identifier_marker}' not found")
-            match action:
-                case RegionClause(region=region):
-                    match region:  # BodyOrWhole | Marker | Segment
-                        case BodyOrWhole():
-                            search_range = identifier_boundaries.location_to_search_range(region)
-                        case _:
-                            search_range = identifier_boundaries.location_to_search_range(BodyOrWhole.WHOLE)
-    return search_range
+def restrict_search_range(
+        region: Region, parent_restriction: any,
+        identifier_finder: IdentifierFinder, lines: Sequence[str]
+) -> RangeSpec:
+    identifier_boundaries = None
+    match parent_restriction:
+        case IdentifierFromFile():
+            identifier_boundaries = identifier_finder(parent_restriction.as_marker)
+    match region:
+        case BodyOrWhole() | RelativePositionType():
+            match parent_restriction:
+                case IdentifierFromFile():
+                    match identifier_boundaries:
+                        case None:
+                            raise ValueError(f"'{parent_restriction}' not found")
+                case SingleFileClause():
+                    return RangeSpec.EMPTY
+                case None:
+                    raise ValueError(f"'{region}' requires parent_restriction")
+                case _:
+                    raise ValueError(f"'{region}' isn't compatible with {parent_restriction}")
+            return identifier_boundaries.location_to_search_range(region)
+        case Marker() as inner_marker:
+            match identifier_finder(inner_marker, identifier_boundaries.whole if identifier_boundaries is not None else None):
+                case IdentifierBoundaries() as inner_boundaries:
+                    return inner_boundaries.location_to_search_range(BodyOrWhole.WHOLE)
+                case RangeSpec() as inner_range_spec:
+                    return inner_range_spec
+                case None:
+                    raise ValueError(f"Unable to find {region}")
+                case _ as invalid:
+                    raise ValueError(f'Invalid: {invalid}')
+        case Segment() as segment:
+            return segment.to_search_range(lines, identifier_boundaries.whole if identifier_boundaries is not None else None)
+        case _ as invalid:
+            raise ValueError(f'Unsupported region type: {type(invalid)}')
 
 
 def restrict_search_range_for_marker(
@@ -320,30 +349,28 @@ def restrict_search_range_for_marker(
     action: EditingAction,
     lines: Sequence[str],
     search_range: RangeSpec,
-    identifier_resolver: Callable[[Marker], IdentifierBoundaries]
+    identifier_finder: IdentifierFinder
 ) -> RangeSpec:
     if marker is None:
         return search_range
 
     match marker:
+        case Marker(type=MarkerType.LINE):
+            search_range = marker.to_search_range(lines, search_range)
+            match action:
+                case InsertClause():
+                    if action.insert_position.qualifier == RelativePositionType.BEFORE:
+                        search_range = search_range.inc()
+                case RegionClause():
+                    search_range = search_range.set_line_count(1)
         case Marker():
-            match marker.type:
-                case MarkerType.LINE:
-                    search_range = marker.to_search_range(lines, search_range)
-                    match action:
-                        case InsertClause():
-                            if action.insert_position.qualifier == RelativePositionType.BEFORE:
-                                search_range = search_range.inc()
-                        case DeleteClause():
-                            search_range = search_range.set_length(1)
-                case _:
-                    identifier_boundaries = identifier_resolver(marker)
-                    if not identifier_boundaries:
-                        raise ValueError(f"'{marker}' not found")
-                    qualifier: RelativePositionType = marker.qualifier if isinstance(
-                        marker, RelativeMarker
-                    ) else RelativePositionType.AT
-                    search_range = identifier_boundaries.location_to_search_range(qualifier)
+            identifier_boundaries = identifier_finder(marker)
+            if not identifier_boundaries:
+                raise ValueError(f"'{marker}' not found")
+            qualifier: RelativePositionType = marker.qualifier if isinstance(
+                marker, RelativeMarker
+            ) else RelativePositionType.AT
+            search_range = identifier_boundaries.location_to_search_range(qualifier)
         case Segment():
             pass  # TODO
     return search_range
diff --git a/src/cedarscript_editor/ed_script_filter.py b/src/cedarscript_editor/ed_script_filter.py
new file mode 100644
index 0000000..a7a0727
--- /dev/null
+++ b/src/cedarscript_editor/ed_script_filter.py
@@ -0,0 +1,45 @@
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Sequence
+
+def process_ed_script(content: Sequence[str], ed_script: str) -> list[str]:
+    """
+    Process an ed script on content using temporary files.
+
+    Args:
+        content: Sequence of strings (lines of the file)
+        ed_script: The ed script commands as a string
+
+    Returns:
+        list[str]: The modified content as a list of strings (lines)
+
+    Raises:
+        RuntimeError: If ed command fails
+    """
+    with tempfile.NamedTemporaryFile(mode='w', suffix='.txt') as content_file:
+
+        # Write content and script to temp files
+        content_file.write('\n'.join(content))
+        content_file.flush()
+
+        # Run ed
+        ed_script = ed_script.strip()
+        # 'H' to Enable verbose errors
+        match ed_script:
+            case '':
+                ed_script = 'H\nw\nq'
+            case _:
+                ed_script = f'H\n{ed_script}\nw\nq'
+        process = subprocess.run(
+            ['ed', content_file.name],
+            input=ed_script,
+            capture_output=True,
+            text=True
+        )
+
+        if process.returncode != 0:
+            raise RuntimeError(f"ed failed: {process.stderr or process.stdout}")
+
+        # Read back the modified content
+        return Path(content_file.name).read_text().splitlines()
diff --git a/src/cedarscript_editor/identifier_selector.py b/src/cedarscript_editor/identifier_selector.py
deleted file mode 100644
index f096a51..0000000
--- a/src/cedarscript_editor/identifier_selector.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from typing import Callable
-
-from cedarscript_ast_parser import Marker
-
-import logging
-
-from cedarscript_editor.python_identifier_finder import find_python_identifier
-from text_manipulation.range_spec import IdentifierBoundaries
-
-_log = logging.getLogger(__name__)
-
-
-def select_finder(
-    root_path: str, file_name: str, source: str
-) -> Callable[[str, str, str, Marker], IdentifierBoundaries | None]:
-    # TODO
-    _log.info("[select_finder] Python selected")
-    return find_python_identifier
diff --git a/src/cedarscript_editor/python_identifier_finder.py b/src/cedarscript_editor/python_identifier_finder.py
deleted file mode 100644
index 77e0c46..0000000
--- a/src/cedarscript_editor/python_identifier_finder.py
+++ /dev/null
@@ -1,74 +0,0 @@
-import rope
-from cedarscript_ast_parser import Marker, MarkerType
-from rope.base import ast, libutils
-from collections.abc import Sequence
-
-from text_manipulation.range_spec import IdentifierBoundaries, RangeSpec
-from text_manipulation.indentation_kit import get_line_indent_count
-
-
-def get_by_offset(obj: Sequence, offset: int):
-    if 0 <= offset < len(obj):
-        return obj[offset]
-    return None
-
-
-def find_python_identifier(root_path: str, file_name: str, source: str, marker: Marker) -> IdentifierBoundaries | None:
-    """
-    Find the starting line index of a specified function in the given lines.
-
-    :param root_path:
-    :param file_name:
-    :param source: Source code.
-    :param marker: Type, name and offset of the identifier to find.
-    TODO: If `None` when there are 2 or more identifiers with the same name, raise exception.
-    :return: IdentifierBoundaries with identifier start, body start, and end lines of the identifier
-    or None if not found.
-    """
-    project = rope.base.project.Project(root_path)
-    resource = libutils.path_to_resource(project, file_name)
-    pymodule = libutils.get_string_module(project, source, resource=resource)
-
-    candidates: list[IdentifierBoundaries] = []
-    lines = source.splitlines()
-    # Use rope's AST to find the identifier
-    match marker.type:
-        case MarkerType.FUNCTION:
-            ast_type = ast.FunctionDef
-        case MarkerType.CLASS:
-            ast_type = ast.ClassDef
-        case _:
-            raise ValueError(f'Invalid identifier type: {marker.type}')
-    for node in ast.walk(pymodule.get_ast()):
-        if not isinstance(node, ast_type) or node.name != marker.value:
-            continue
-        start_line = node.lineno
-        body_start_line = node.body[0].lineno if node.body else start_line
-        # Find the last line by traversing all child nodes
-        end_line = start_line
-        for child in ast.walk(node):
-            if hasattr(child, 'lineno'):
-                end_line = max(end_line, child.lineno)
-        # TODO Set indentation for all 3 lines
-        candidates.append(IdentifierBoundaries(
-            RangeSpec(start_line - 1, end_line, get_line_indent_count(lines[start_line - 1])),
-            RangeSpec(body_start_line - 1, end_line, get_line_indent_count(lines[body_start_line - 1]))
-        ))
-
-    candidate_count = len(candidates)
-    if not candidate_count:
-        return None
-    if candidate_count > 1 and marker.offset is None:
-        raise ValueError(
-            f"There are {candidate_count} functions named `{marker.value}` in file `{file_name}`. "
-            f"Use `OFFSET <0..{candidate_count - 1}>` to determine how many to skip. "
-            f"Example to reference the *last* `{marker.value}`: `OFFSET {candidate_count - 1}`"
-        )
-    if marker.offset and marker.offset >= candidate_count:
-        raise ValueError(
-            f"There are only {candidate_count} functions named `{marker.value} in file `{file_name}`, "
-            f"but 'offset' was set to {marker.offset} (you can only skip {candidate_count - 1} functions)"
-        )
-    candidates.sort(key=lambda x: x.start_line)
-    result: IdentifierBoundaries = get_by_offset(candidates, marker.offset or 0)
-    return result
diff --git a/src/cedarscript_editor/tree_sitter_identifier_finder.py b/src/cedarscript_editor/tree_sitter_identifier_finder.py
new file mode 100644
index 0000000..586e0ef
--- /dev/null
+++ b/src/cedarscript_editor/tree_sitter_identifier_finder.py
@@ -0,0 +1,331 @@
+import logging
+from dataclasses import dataclass
+from functools import cached_property
+from typing import Sequence, Iterable
+
+from cedarscript_ast_parser import Marker, MarkerType, Segment, RelativeMarker
+from grep_ast import filename_to_lang
+from text_manipulation.indentation_kit import get_line_indent_count
+from text_manipulation.range_spec import IdentifierBoundaries, RangeSpec, ParentInfo, ParentRestriction
+from text_manipulation import IdentifierFinder
+from tree_sitter_languages import get_language, get_parser
+from pylibtreesitter import nodes_by_type_suffix
+from .tree_sitter_identifier_queries import get_query
+
+"""
+Parser for extracting identifier information from source code using tree-sitter.
+Supports multiple languages and provides functionality to find and analyze identifiers
+like functions and classes along with their hierarchical relationships.
+"""
+
+_log = logging.getLogger(__name__)
+
+
+class TreeSitterIdentifierFinder(IdentifierFinder):
+    """Finds identifiers in source code based on markers and parent restrictions.
+
+    Attributes:
+        lines: List of source code lines
+        file_path: Path to the source file
+        source: Complete source code as a single string
+        language: Tree-sitter language instance
+        tree: Parsed tree-sitter tree
+        query_info: Language-specific query information
+    """
+
+    def __init__(self, fname: str, source: str | Sequence[str], parent_restriction: ParentRestriction = None):
+        super().__init__()
+        self.parent_restriction = parent_restriction
+        match source:
+            case str() as s:
+                self.lines = s.splitlines()
+            case _ as lines:
+                self.lines = lines
+                source = '\n'.join(lines)
+        langstr = filename_to_lang(fname)
+        if langstr is None:
+            self.language = None
+            self.query_info = None
+            _log.info(f"[TreeSitterIdentifierFinder] NO LANGUAGE for `{fname}`")
+            return
+        self.query_info: dict[str, str] = get_query(langstr)
+        self.language = get_language(langstr)
+        _log.info(f"[TreeSitterIdentifierFinder] Selected {self.language}")
+        self.tree = get_parser(langstr).parse(bytes(source, "utf-8"))
+
+    def __call__(
+            self, mos: Marker | Segment, parent_restriction: ParentRestriction = None
+    ) -> IdentifierBoundaries | RangeSpec | None:
+        parent_restriction = parent_restriction or self.parent_restriction
+        match mos:
+            case Marker(MarkerType.LINE) | Segment():
+                # TODO pass IdentifierFinder to enable identifiers as start and/or end of a segment
+                return mos.to_search_range(self.lines, parent_restriction).set_line_count(1)  # returns RangeSpec
+
+            case Marker() as marker:
+                # Returns IdentifierBoundaries
+                return self._find_identifier(marker, parent_restriction)
+
+
+    def _find_identifier(self,
+        marker: Marker,
+        parent_restriction: ParentRestriction
+    ) -> IdentifierBoundaries | RangeSpec | None:
+        """Finds an identifier in the source code using tree-sitter queries.
+
+        Args:
+            language: Tree-sitter language
+            source: List of source code lines
+            tree: Parsed tree-sitter tree
+            query_scm: Dictionary of queries for different identifier types
+            marker: Type, name and offset of the identifier to find
+
+        Returns:
+            IdentifierBoundaries with identifier IdentifierBoundaries with identifier start, body start, and end lines of the identifier
+        or None if not found
+        """
+        query_info_key = marker.type
+        identifier_name = marker.value
+        try:
+            all_restrictions: list[ParentRestriction] = [parent_restriction]
+            # Extract parent name if using dot notation
+            if '.' in identifier_name:
+                *parent_parts, identifier_name = identifier_name.split('.')
+                all_restrictions.append("." + '.'.join(reversed(parent_parts)))
+
+            identifier_type = marker.type
+            # Get all node candidates first
+            candidates = self.find_identifiers(query_info_key, identifier_name, all_restrictions)
+        except Exception as e:
+            raise ValueError(f"Unable to capture nodes for {marker}: {e}") from e
+
+        candidate_count = len(candidates)
+        if not candidate_count:
+            return None
+        if candidate_count > 1 and marker.offset is None:
+            raise ValueError(
+                f"The {marker.type} identifier named `{identifier_name}` is ambiguous (found {candidate_count} matches). "
+                f"Choose an `OFFSET` between 0 and {candidate_count - 1} to determine how many to skip. "
+                f"Example to reference the *last* `{identifier_name}`: `OFFSET {candidate_count - 1}`"
+            )
+        if marker.offset and marker.offset >= candidate_count:
+            raise ValueError(
+                f"There are only {candidate_count} {marker.type} identifiers named `{identifier_name}`, "
+                f"but 'OFFSET' was set to {marker.offset} (you can skip at most {candidate_count - 1} of those)"
+            )
+        candidates.sort(key=lambda x: x.whole.start)
+        result: IdentifierBoundaries = _get_by_offset(candidates, marker.offset or 0)
+        match marker:
+            case RelativeMarker(qualifier=relative_position_type):
+                return result.location_to_search_range(relative_position_type)
+        return result
+
+    def find_identifiers(
+        self, identifier_type: str, name: str, all_restrictions: list[ParentRestriction] = []
+    ) -> list[IdentifierBoundaries]:
+        if not self.language:
+            return []
+        match identifier_type:
+            case 'method':
+                identifier_type = 'function'
+        _query = self.query_info[identifier_type].format(name=name)
+        candidate_nodes = self.language.query(_query).captures(self.tree.root_node)
+        if not candidate_nodes:
+            return []
+        # Convert captures to boundaries and filter by parent
+        candidates: list[IdentifierBoundaries] = []
+        for ib in capture2identifier_boundaries(candidate_nodes, self.lines):
+            # For methods, verify the immediate parent is a class
+            if identifier_type == 'method':
+                if not ib.parents or not ib.parents[0].parent_type.startswith('class'):
+                    continue
+            # Check parent restriction (e.g., specific class name)
+            candidate_matched_all_restrictions = True
+            for pr in all_restrictions:
+                if not ib.match_parent(pr):
+                    candidate_matched_all_restrictions = False
+                    break
+            if candidate_matched_all_restrictions:
+                candidates.append(ib)
+        return candidates
+
+
+def _get_by_offset(obj: Sequence, offset: int):
+    if 0 <= offset < len(obj):
+        return obj[offset]
+    return None
+
+
+@dataclass(frozen=True)
+class CaptureInfo:
+    """Container for information about a captured node from tree-sitter parsing.
+
+    Attributes:
+        capture_type: Type of the captured node (e.g., 'function.definition')
+        node: The tree-sitter node that was captured
+
+    Properties:
+        node_type: Type of the underlying node
+        range: Tuple of (start_line, end_line)
+        identifier: Name of the identifier if this is a name capture
+        parents: List of (node_type, node_name) tuples representing the hierarchy
+    """
+    capture_type: str
+    node: any
+
+    def to_range_spec(self, lines: Sequence[str]) -> RangeSpec:
+        start, end = self.range
+        return RangeSpec(start, end + 1, get_line_indent_count(lines[start]))
+
+    @property
+    def node_type(self):
+        return self.node.type
+
+    @property
+    def range(self):
+        return self.node.range.start_point[0], self.node.range.end_point[0]
+
+    @property
+    def identifier(self):
+        if not self.capture_type.endswith('.name'):
+            return None
+        return self.node.text.decode("utf-8")
+
+    @cached_property
+    def parents(self) -> list[ParentInfo]:
+        """Returns a list of (node_type, node_name) tuples representing the hierarchy.
+        The list is ordered from immediate parent to root."""
+        parents: list[ParentInfo] = []
+        current = self.node.parent
+
+        while current:
+            # Check if current node is a container type we care about - TODO exact field depends on language
+            if current.type.endswith('_definition') and current.type != 'decorated_definition':
+                # Try to find the name node - TODO exact field depends on language
+                name = None
+                for child in current.children:
+                    if child.type == 'identifier' or child.type == 'name':
+                        name = child.text.decode('utf-8')
+                        break
+                parents.append(ParentInfo(name, current.type))
+            current = current.parent
+
+        return parents
+
+
+def associate_identifier_parts(captures: Iterable[CaptureInfo], lines: Sequence[str]) -> list[IdentifierBoundaries]:
+    """Associates related identifier parts (definition, body, docstring, etc) into IdentifierBoundaries.
+
+    Args:
+        captures: Iterable of CaptureInfo objects representing related parts
+        lines: Sequence of source code lines
+
+    Returns:
+        List of IdentifierBoundaries with all parts associated
+    """
+    identifier_map: dict[int, IdentifierBoundaries] = {}
+
+    for capture in captures:
+        capture_type = capture.capture_type.split('.')[-1]
+        range_spec = capture.to_range_spec(lines)
+        if capture_type == 'definition':
+            identifier_map[range_spec.start] = IdentifierBoundaries(
+                whole=range_spec,
+                parents=capture.parents
+            )
+
+        else:
+            parent = find_parent_definition(capture.node)
+            if parent:
+                parent_key = parent.start_point[0]
+                parent = identifier_map.get(parent_key)
+            if parent is None:
+                raise ValueError(f'Parent node not found for [{capture.capture_type} - {capture.node_type}] ({capture.node.text.decode("utf-8").strip()})')
+            match capture_type:
+                case 'body':
+                    parent.body=range_spec
+                case 'docstring':
+                    parent.docstring=range_spec
+                case 'decorator':
+                    parent.append_decorator(range_spec)
+                case _ as invalid:
+                    raise ValueError(f'Invalid capture type: {invalid}')
+
+    return sorted(identifier_map.values(), key=lambda x: x.whole.start)
+
+
+def find_parent_definition(node):
+    """Returns the first parent node that ends with '_definition'"""
+    # TODO How to deal with 'decorated_definition' ?
+    while node.parent:
+        node = node.parent
+        if node.type.endswith('_definition'):
+            if node.type == 'decorated_definition':
+                node = nodes_by_type_suffix(node.named_children, '_definition')
+                if node:
+                    if len(node) > 1:
+                        raise ValueError(f'{len(node)} parent definitions found: {node}')
+                    return node[0]
+            return node
+    return None
+
+
+def capture2identifier_boundaries(captures, lines: Sequence[str]) -> list[IdentifierBoundaries]:
+    """Converts raw tree-sitter captures to IdentifierBoundaries objects.
+
+    Args:
+        captures: Raw captures from tree-sitter query
+        lines: Sequence of source code lines
+
+    Returns:
+        List of IdentifierBoundaries representing the captured identifiers
+    """
+    captures = [CaptureInfo(c[1], c[0]) for c in captures if not c[1].startswith('_')]
+    unique_captures = {}
+    for capture in captures:
+        unique_captures[f'{capture.range[0]}:{capture.capture_type}'] = capture
+    # unique_captures={
+    # '14:function.definition': CaptureInfo(capture_type='function.definition', node=<Node type=function_definition, start_point=(14, 4), end_point=(22, 19)>),
+    # '12:function.decorator': CaptureInfo(capture_type='function.decorator', node=<Node type=decorator, start_point=(12, 4), end_point=(12, 17)>),
+    # '13:function.decorator': CaptureInfo(capture_type='function.decorator', node=<Node type=decorator, start_point=(13, 4), end_point=(13, 28)>),
+    # '15:function.body': CaptureInfo(capture_type='function.body', node=<Node type=block, start_point=(15, 8), end_point=(22, 19)>),
+    # '15:function.docstring': CaptureInfo(capture_type='function.docstring', node=<Node type=string, start_point=(15, 8), end_point=(15, 42)>)
+    # }
+    return associate_identifier_parts(sort_captures(unique_captures), lines)
+
+def parse_capture_key(key):
+    """
+    Parses the dictionary key into line number and capture type.
+    Args:
+        key (str): The key in the format 'line_number:capture_type'.
+    Returns:
+        tuple: (line_number as int, capture_type as str)
+    """
+    line_number, capture_type = key.split(':')
+    return int(line_number), capture_type.split('.')[-1]
+
+def get_sort_priority():
+    """
+    Returns a dictionary mapping capture types to their sort priority.
+    Returns:
+        dict: Capture type priorities.
+    """
+    return {'definition': 1, 'decorator': 2, 'body': 3, 'docstring': 4}
+
+def sort_captures(captures):
+    """
+    Sorts the values of the captures dictionary by capture type and line number.
+    Args:
+        captures (dict): The dictionary to sort.
+    Returns:
+        list: Sorted list of values.
+    """
+    priority = get_sort_priority()
+    sorted_items = sorted(
+        captures.items(),
+        key=lambda item: (
+            priority[parse_capture_key(item[0])[1]],  # Sort by capture type priority
+            parse_capture_key(item[0])[0]  # Then by line number
+        )
+    )
+    return [value for _, value in sorted_items]
diff --git a/src/cedarscript_editor/tree_sitter_identifier_queries.py b/src/cedarscript_editor/tree_sitter_identifier_queries.py
new file mode 100644
index 0000000..1f58fc3
--- /dev/null
+++ b/src/cedarscript_editor/tree_sitter_identifier_queries.py
@@ -0,0 +1,54 @@
+# # Credits
+# 
+# CEDARScript uses modified versions of the tags.scm files from these open source
+# tree-sitter language implementations:
+# 
+# * [https://github.com/tree-sitter/tree-sitter-c](https://github.com/tree-sitter/tree-sitter-c) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-c-sharp](https://github.com/tree-sitter/tree-sitter-c-sharp) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-cpp](https://github.com/tree-sitter/tree-sitter-cpp) — licensed under the MIT License.
+# * [https://github.com/Wilfred/tree-sitter-elisp](https://github.com/Wilfred/tree-sitter-elisp) — licensed under the MIT License.
+# * [https://github.com/elixir-lang/tree-sitter-elixir](https://github.com/elixir-lang/tree-sitter-elixir) — licensed under the Apache License, Version 2.0.
+# * [https://github.com/elm-tooling/tree-sitter-elm](https://github.com/elm-tooling/tree-sitter-elm) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-go](https://github.com/tree-sitter/tree-sitter-go) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-java](https://github.com/tree-sitter/tree-sitter-java) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-javascript](https://github.com/tree-sitter/tree-sitter-javascript) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-ocaml](https://github.com/tree-sitter/tree-sitter-ocaml) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-php](https://github.com/tree-sitter/tree-sitter-php) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-python](https://github.com/tree-sitter/tree-sitter-python) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-ql](https://github.com/tree-sitter/tree-sitter-ql) — licensed under the MIT License.
+# * [https://github.com/r-lib/tree-sitter-r](https://github.com/r-lib/tree-sitter-r) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-ruby](https://github.com/tree-sitter/tree-sitter-ruby) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-rust](https://github.com/tree-sitter/tree-sitter-rust) — licensed under the MIT License.
+# * [https://github.com/tree-sitter/tree-sitter-typescript](https://github.com/tree-sitter/tree-sitter-typescript) — licensed under the MIT License.
+
+# query_scm = get_scm_fname(langstr)
+# query_scm = query_scm.read_text()
+# def get_scm_fname(langstr):
+#     # Load the tags queries
+#     try:
+#         return resources.files(__package__).joinpath("queries", f"tree-sitter-{langstr}-tags.scm")
+#     except KeyError:
+#         return
+
+from importlib.resources import files
+
+_tree_sitter_queries = files("tree-sitter-queries")
+
+
+def get_query(langstr: str) -> dict[str, str]:
+    basedir = _tree_sitter_queries / langstr
+    if not basedir.exists():
+        raise KeyError(f"Missing language dir: {basedir}")
+    base_template = (basedir / "basedef.scm").read_text(encoding='utf-8')
+    common_template = (basedir / "common.scm").read_text(encoding='utf-8')
+    templates2 = {
+        "function": (basedir / "functions.scm").read_text(encoding='utf-8'),
+        "class": (basedir / "classes.scm").read_text(encoding='utf-8')
+    }
+    return {
+        _type: templates2[_type].format(
+            definition_base=base_template.format(type=_type),
+            common_body=common_template.format(type=_type)
+        )
+        for _type in ["function", "class"]
+    }
diff --git a/src/text_manipulation/__init__.py b/src/text_manipulation/__init__.py
index e69de29..96a6f58 100644
--- a/src/text_manipulation/__init__.py
+++ b/src/text_manipulation/__init__.py
@@ -0,0 +1,17 @@
+from version import __version__
+from .line_kit import get_line_indent_count, extract_indentation
+from .range_spec import RangeSpec, IdentifierBoundaries
+from .text_editor_kit import read_file, write_file, bow_to_search_range
+from .cst_kit import IdentifierFinder
+from .indentation_kit import IndentationInfo
+
+__all__ = [
+    "__version__",
+    "IndentationInfo",
+    "IdentifierBoundaries",
+    "IdentifierFinder",
+    "RangeSpec",
+    "read_file",
+    "write_file",
+    "bow_to_search_range",
+]
diff --git a/src/text_manipulation/cst_kit.py b/src/text_manipulation/cst_kit.py
new file mode 100644
index 0000000..024b2c5
--- /dev/null
+++ b/src/text_manipulation/cst_kit.py
@@ -0,0 +1,26 @@
+from typing import runtime_checkable, Protocol, Sequence
+from functools import cached_property
+from cedarscript_ast_parser import Marker, Segment, RelativeMarker, RelativePositionType, MarkerType, BodyOrWhole
+
+from .range_spec import IdentifierBoundaries, RangeSpec, ParentRestriction
+from .text_editor_kit import read_file, write_file, bow_to_search_range
+
+
+@runtime_checkable
+class IdentifierFinder(Protocol):
+    """Protocol for finding identifiers in source code."""
+
+    def __call__(
+            self, mos: Marker | Segment, parent_restriction: ParentRestriction = None
+    ) -> IdentifierBoundaries | RangeSpec | None:
+        """Find identifier boundaries for a given marker or segment."""
+        pass
+
+    def find_identifiers(
+        self, identifier_type: str, name: str, all_restrictions: list[ParentRestriction] | None = None
+    ) -> list[IdentifierBoundaries]:
+        pass
+
+    @cached_property
+    def find_all_callables(self) -> list[IdentifierBoundaries]:
+        return self.find_identifiers('function', r'.*')
\ No newline at end of file
diff --git a/src/text_manipulation/indentation_kit.py b/src/text_manipulation/indentation_kit.py
index f339dd0..c9168be 100644
--- a/src/text_manipulation/indentation_kit.py
+++ b/src/text_manipulation/indentation_kit.py
@@ -1,43 +1,39 @@
-import re
-from collections import Counter
-from collections.abc import Sequence
-from math import gcd
-from typing import NamedTuple
+"""
+This module provides utilities for analyzing and manipulating indentation in text.
 
+It includes functions and classes for extracting indentation, analyzing indentation
+patterns, and adjusting indentation levels in text content. These tools are particularly
+useful for processing and formatting source code or other text with structured indentation.
 
-def get_line_indent_count(line: str):
-    return len(line) - len(line.lstrip())
+Key components:
+- get_line_indent_count: Counts the number of leading whitespace characters in a line.
+- extract_indentation: Extracts the leading whitespace from a line.
+- IndentationInfo: A class that analyzes and represents indentation patterns in text.
 
+This module is designed to work with various indentation styles, including spaces and tabs,
+and can handle inconsistent or mixed indentation patterns.
+"""
 
-def extract_indentation(line: str) -> str:
-    """
-    Extract the leading whitespace from a given line.
+from collections import Counter
+from collections.abc import Sequence
+from math import gcd
+from typing import NamedTuple
+import re
 
-    Args:
-        line (str): The input line to process.
+from .cst_kit import IdentifierFinder
 
-    Returns:
-        str: The leading whitespace of the line.
+from .line_kit import get_line_indent_count, extract_indentation
 
-    Examples:
-        >>> extract_indentation("    Hello")
-        '    '
-        >>> extract_indentation("\t\tWorld")
-        '\t\t'
-        >>> extract_indentation("No indentation")
-        ''
-    """
-    return line[:len(line) - len(line.lstrip())]
+relative_indent_prefix = re.compile(r'^\s*@(-?\d+):(.*)')
 
 
 class IndentationInfo(NamedTuple):
     """
     A class to represent and manage indentation information.
 
-    This class analyzes and provides utilities for working with indentation.
-    It detects the indentation character (space or tab),
-    the number of characters used for each indentation level, and provides
-    methods to adjust and normalize indentation.
+    This class analyzes and provides utilities for working with indentation in text content.
+    It detects the indentation character (space or tab), the number of characters used for
+    each indentation level, and provides methods to adjust and normalize indentation.
 
     Attributes:
         char_count (int): The number of characters used for each indentation level.
@@ -57,9 +53,16 @@ class IndentationInfo(NamedTuple):
         apply_relative_indents: Applies relative indentation based on annotations in the content.
 
     Note:
-        This class is particularly useful for processing Python code with varying
+        This class is particularly useful for processing code or text with varying
         or inconsistent indentation, and for adjusting indentation to meet specific
-        formatting requirements.
+        formatting requirements. It can handle both space and tab indentation, as well
+        as mixed indentation styles.
+
+    Example:
+        >>> content = "    def example():\n        print('Hello')\n\t\tprint('World')"
+        >>> info = IndentationInfo.from_content(content)
+        >>> print(info.char, info.char_count, info.consistency)
+        ' ' 4 False
     """
     char_count: int
     char: str
@@ -68,7 +71,65 @@ class IndentationInfo(NamedTuple):
     message: str | None = None
 
     @classmethod
-    def from_content[T: IndentationInfo, S: Sequence[str]](cls: T, content: str | S) -> T:
+    def default(cls) -> 'IndentationInfo':
+        return cls(4, ' ', 0)
+
+    @classmethod
+    def shift_indentation(cls,
+        content: Sequence[str], target_lines: Sequence[str], target_reference_indentation_count: int,
+        relindent_level: int | None = None,
+        identifier_finder: IdentifierFinder | None = None
+    ) -> list[str]:
+        """
+        Returns 'content' with shifted indentation based on a relative indent level and a reference indentation count.
+
+        This method adjusts the indentation of each non-empty line in the input sequence.
+        It calculates the difference between the target base indentation and the minimum
+        indentation found in the content, then applies this shift to all lines.
+
+        Args:
+            content (Sequence[str]): A sequence of strings representing the lines to be adjusted.
+            target_reference_indentation_count (int): The target base indentation count to adjust to.
+            relindent_level (int|None):
+
+        Returns:
+            list[str]: A new list of strings with adjusted indentation.
+
+        Note:
+            - Empty lines and lines with only whitespace are preserved as-is.
+            - The method uses the IndentationInfo of the instance to determine
+              the indentation character and count.
+            - This method is useful for uniformly adjusting indentation across all lines.
+
+        Example:
+            >>> info = IndentationInfo(4, ' ', 1, True)
+            >>> lines = ["    def example():", "        print('Hello')"]
+            >>> info.shift_indentation(content, 8)
+            ['        def example():', '            print('Hello')']
+            :param target_lines:
+        """
+        context_indent_char_count = cls.from_content(target_lines, identifier_finder).char_count
+        return (cls.
+            from_content(content, identifier_finder).
+            _replace(char_count=context_indent_char_count).
+            _shift_indentation(
+                content, target_reference_indentation_count, relindent_level
+            )
+        )
+
+    def _shift_indentation(
+        self,
+        content: Sequence[str], target_base_indentation_count: int, relindent_level: int | None
+    ) -> list[str]:
+        target_base_indentation_count += self.char_count * (relindent_level or 0)
+        raw_line_adjuster = self._shift_indentation_fun(target_base_indentation_count)
+        return [raw_line_adjuster(line) for line in content]
+
+    @classmethod
+    def from_content(
+        cls, content: str | Sequence[str],
+        identifier_finder: IdentifierFinder | None = None
+    ) -> 'IndentationInfo':
         """
         Analyzes the indentation in the given content and creates an IndentationInfo instance.
 
@@ -89,39 +150,40 @@ def from_content[T: IndentationInfo, S: Sequence[str]](cls: T, content: str | S)
               character count by analyzing patterns and using GCD.
         """
         # TODO Always send str?
-        lines = [x.lstrip() for x in content.splitlines() if x.strip()] if isinstance(content, str) else content
-
-        indentations = [extract_indentation(line) for line in lines if line.strip()]
-
-        if not indentations:
-            return cls(4, ' ', 0, True, "No indentation found. Assuming 4 spaces (PEP 8).")
-
-        indent_chars = Counter(indent[0] for indent in indentations if indent)
-        dominant_char = ' ' if indent_chars.get(' ', 0) >= indent_chars.get('\t', 0) else '\t'
+        indent_lengths = []
+        if identifier_finder:
+            indent_lengths = []
+            for ib in identifier_finder.find_all_callables:
+                if ib.whole and ib.whole.indent:
+                    indent_lengths.append(ib.whole.indent)
+                if ib.body and ib.body.indent:
+                    indent_lengths.append(ib.body.indent)
+            has_zero_indent = any((i == 0 for i in indent_lengths))
+
+        if not (indent_lengths):
+            lines = [x for x in content.splitlines() if x.strip()] if isinstance(content, str) else content
+            indentations = [extract_indentation(line) for line in lines if line.strip()]
+            has_zero_indent = any((i == '' for i in indentations))
+            indentations = [indent for indent in indentations if indent]
+
+            if not indentations:
+                return cls(4, ' ', 0, True, "No indentation found. Assuming 4 spaces (PEP 8).")
+
+            indent_chars = Counter(indent[0] for indent in indentations)
+            dominant_char = ' ' if indent_chars.get(' ', 0) >= indent_chars.get('\t', 0) else '\t'
+
+            indent_lengths = [len(indent) for indent in indentations]
+        else:
+            dominant_char = ' '
 
-        indent_lengths = [len(indent) for indent in indentations]
+        char_count = 1
+        if dominant_char != '\t':
+            char_count = cls.calc_space_count_for_indent(indent_lengths)
 
-        if dominant_char == '\t':
-            char_count = 1
-        else:
-            # For spaces, determine the most likely char_count
-            space_counts = [sc for sc in indent_lengths if sc % 2 == 0 and sc > 0]
-            if not space_counts:
-                char_count = 2  # Default to 2 if no even space counts
-            else:
-                # Sort top 5 space counts and find the largest GCD
-                sorted_counts = sorted([c[0] for c in Counter(space_counts).most_common(5)], reverse=True)
-                char_count = sorted_counts[0]
-                for i in range(1, len(sorted_counts)):
-                    new_gcd = gcd(char_count, sorted_counts[i])
-                    if new_gcd <= 1:
-                        break
-                    char_count = new_gcd
-
-        min_indent_chars = min(indent_lengths) if indent_lengths else 0
+        min_indent_chars = 0 if has_zero_indent else min(indent_lengths) if indent_lengths else 0
         min_indent_level = min_indent_chars // char_count
 
-        consistency = all(len(indent) % char_count == 0 for indent in indentations if indent)
+        consistency = all(indent_len % char_count == 0 for indent_len in indent_lengths if indent_len)
         match dominant_char:
             case ' ':
                 domcharstr = 'space'
@@ -135,39 +197,71 @@ def from_content[T: IndentationInfo, S: Sequence[str]](cls: T, content: str | S)
 
         return cls(char_count, dominant_char, min_indent_level, consistency, message)
 
-    def level_difference(self, base_indentation_count: int):
+    @staticmethod
+    def calc_space_count_for_indent(indent_lengths: Sequence[int]) -> int:
+        # For spaces, determine the most likely char_count
+        space_counts = [sc for sc in indent_lengths if sc % 2 == 0]
+        if not space_counts:
+            return 2  # Default to 2 if no even space counts
+
+        unique_space_counts = sorted(set(space_counts))
+        if len(unique_space_counts) == 1:
+            return unique_space_counts[0]
+
+        deltas = sorted([b - a for a, b in zip(unique_space_counts, unique_space_counts[1:])], reverse=True)
+        most_common_deltas = Counter(deltas).most_common(5)
+        ratio_most_common = most_common_deltas[0][1] / len(deltas)
+        if ratio_most_common > .5:
+            return most_common_deltas[0][0]
+
+        # Resort to GCD
+        result = deltas[0]
+        # find the largest GCD
+        for i in range(1, len(most_common_deltas)):
+            new_gcd = gcd(result, most_common_deltas[i][0])
+            if new_gcd <= 1:
+                break
+            result = new_gcd
+        return result
+
+    def update_min_indent_level(self, content: str | Sequence[str]) -> 'IndentationInfo':
+        return self._replace(min_indent_level=IndentationInfo.from_content(content).min_indent_level)
+
+    def level_difference(self, base_indentation_count: int) -> int:
+        """
+        Calculate the difference in indentation levels.
+
+        Args:
+            base_indentation_count (int): The base indentation count to compare against.
+
+        Returns:
+            int: The difference in indentation levels.
+        """
         return self.char_count_to_level(base_indentation_count) - self.min_indent_level
 
     def char_count_to_level(self, char_count: int) -> int:
-        return char_count // self.char_count
+        """
+        Convert a character count to an indentation level.
 
-    def level_to_chars(self, level: int) -> str:
-        return level * self.char_count * self.char
+        Args:
+            char_count (int): The number of indentation characters.
 
-    def shift_indentation(self, lines: Sequence[str], target_base_indentation_count: int) -> list[str]:
+        Returns:
+            int: The corresponding indentation level.
         """
-        Shifts the indentation of a sequence of lines based on a base indentation count.
+        return char_count // self.char_count
 
-        This method adjusts the indentation of each non-empty line in the input sequence.
-        It calculates the difference between the base indentation and the minimum
-        indentation found in the content, then applies this shift to all lines.
+    def level_to_chars(self, level: int) -> str:
+        """
+        Convert an indentation level to a string of indentation characters.
 
         Args:
-            lines (Sequence[str]): A sequence of strings representing the lines to be adjusted.
-            target_base_indentation_count (int): The base indentation count to adjust from.
+            level (int): The indentation level.
 
         Returns:
-            list[str]: A new list of strings with adjusted indentation.
-
-        Note:
-            - Empty lines and lines with only whitespace are preserved as-is.
-            - The method uses the IndentationInfo of the instance to determine
-              the indentation character and count.
-            - This method is useful for uniformly adjusting indentation across all lines.
+            str: A string of indentation characters for the given level.
         """
-        raw_line_adjuster = self._shift_indentation_fun(target_base_indentation_count)
-        # Return the transformed lines
-        return [raw_line_adjuster(line) for line in lines]
+        return level * self.char_count * self.char
 
     def _shift_indentation_fun(self, target_base_indentation_count: int):
         # Calculate the indentation difference
@@ -186,9 +280,12 @@ def adjust_line(line: str) -> str:
             return new_indent + line.lstrip()
         return adjust_line
 
-    def apply_relative_indents[S: Sequence[str]](self, content: str | S, context_indent_count: int = 0) -> list[str]:
+    def apply_relative_indents(
+            self, content: str | Sequence[str], reference_indent_count: int = 0,
+            treat_unprefixed_line_as_relative: bool = False
+    ) -> list[str]:
         """
-        Applies relative indentation based on annotations in the content.
+        Apply relative indentation based on optional annotations in the content.
 
         This method processes the input content, interpreting special annotations
         to apply relative indentation. It uses '@' followed by a number to indicate
@@ -197,7 +294,7 @@ def apply_relative_indents[S: Sequence[str]](self, content: str | S, context_ind
         Args:
             content (str | Sequence[str]): The content to process. Can be a string
                                            or a sequence of strings.
-            context_indent_count (int, optional): The base indentation count of the
+            reference_indent_count (int, optional): The base indentation count of the
                                                   context. Defaults to 0.
 
         Returns:
@@ -214,22 +311,37 @@ def apply_relative_indents[S: Sequence[str]](self, content: str | S, context_ind
 
         Raises:
             AssertionError: If the calculated indentation level for any line is negative.
+
+        Example:
+            >>> info = IndentationInfo(4, ' ', 0, True)
+            >>> content = ["@0:def example():", "@1:    print('Hello')", "@2:    if True:", "@3:        print('World')"]
+            >>> info.apply_relative_indents(content, 4)
+            ['    def example():', '        print('Hello')', '            if True:', '                print('World')']
         """
         # TODO Always send str?
-        lines = [line.lstrip() for line in content.splitlines() if line.strip()] if isinstance(content, str) else content
-
-        context_indent_level = self.char_count_to_level(context_indent_count)
+        lines = [l for l in content.strip('\n').splitlines()] if isinstance(content, str) else content
+        reference_indent_level = self.char_count_to_level(reference_indent_count)
         for i in range(len(lines)):
             line = lines[i]
-            parts = line.split(':', 1)
-            if len(parts) == 2 and parts[0].startswith('@'):
-                relative_indent_level = int(parts[0][1:])
-                absolute_indent_level = context_indent_level + relative_indent_level
-                assert absolute_indent_level >= 0, f"Final indentation for line `{line.strip()}` cannot be negative ({absolute_indent_level})"
-                lines[i] = self.level_to_chars(absolute_indent_level) + parts[1].lstrip()
-            else:
-                absolute_indent_level = context_indent_level
-                lines[i] = self.level_to_chars(absolute_indent_level) + line.lstrip()
+            match relative_indent_prefix.match(line):
+                case re.Match() as m:
+                    relative_indent_level, line = m.groups()
+                    relative_indent_level = int(relative_indent_level)
+                    line = line.lstrip()
+                    absolute_indent_level = reference_indent_level + relative_indent_level
+                case _:
+                    if treat_unprefixed_line_as_relative:
+                        line = line.lstrip()
+                        relative_indent_level = self.char_count_to_level(get_line_indent_count(line))
+                        absolute_indent_level = reference_indent_level + relative_indent_level
+                    else:
+                        absolute_indent_level = 0
+
+            assert absolute_indent_level >= 0, (
+                f"Final indent level for line `{line.strip()}` cannot be negative "
+                f"({absolute_indent_level})"
+            )
+            lines[i] = self.level_to_chars(absolute_indent_level) + line
 
         return lines
 
diff --git a/src/text_manipulation/line_kit.py b/src/text_manipulation/line_kit.py
new file mode 100644
index 0000000..d8403a4
--- /dev/null
+++ b/src/text_manipulation/line_kit.py
@@ -0,0 +1,47 @@
+from typing import Sequence
+
+from collections.abc import Sequence
+
+def get_line_indent_count_from_lines(lines: Sequence[str], index: int) -> int:
+    return get_line_indent_count(lines[index])
+
+def get_line_indent_count(line: str) -> int:
+    """
+    Count the number of leading whitespace characters in a line.
+
+    Args:
+        line (str): The input line to analyze.
+
+    Returns:
+        int: The number of leading whitespace characters.
+
+    Example:
+        >>> get_line_indent_count("    Hello")
+        4
+        >>> get_line_indent_count("\t\tWorld")
+        2
+    """
+    return len(line) - len(line.lstrip())
+
+def extract_indentation(line: str) -> str:
+    """
+    Extract the leading whitespace from a given line.
+
+    This function identifies and returns the leading whitespace characters
+    (spaces or tabs) from the beginning of the input line.
+
+    Args:
+        line (str): The input line to process.
+
+    Returns:
+        str: The leading whitespace of the line.
+
+    Examples:
+        >>> extract_indentation("    Hello")
+        '    '
+        >>> extract_indentation("\t\tWorld")
+        '\t\t'
+        >>> extract_indentation("No indentation")
+        ''
+    """
+    return line[:get_line_indent_count(line)]
diff --git a/src/text_manipulation/range_spec.py b/src/text_manipulation/range_spec.py
index 5d882a4..1f9a6e8 100644
--- a/src/text_manipulation/range_spec.py
+++ b/src/text_manipulation/range_spec.py
@@ -1,14 +1,42 @@
+"""
+This module provides utilities for working with ranges of text in source code.
+
+It includes classes and functions for specifying ranges, finding lines,
+and manipulating text within those ranges. The main components are:
+
+- RangeSpec: A class representing a range of lines in a text.
+- IdentifierBoundaries: A class representing the boundaries of an identifier in code.
+- Various utility functions for working with these classes and text manipulation.
+"""
+
 import re
 from collections.abc import Sequence
-from typing import NamedTuple
+from typing import NamedTuple, TypeAlias
+from functools import total_ordering
+from dataclasses import dataclass, field
+
 
 from cedarscript_ast_parser import Marker, RelativeMarker, RelativePositionType, MarkerType, BodyOrWhole
-from text_manipulation.indentation_kit import get_line_indent_count
+
+from .line_kit import get_line_indent_count_from_lines
 
 MATCH_TYPES = ('exact', 'stripped', 'normalized', 'partial')
 
 
+@total_ordering
 class RangeSpec(NamedTuple):
+    """
+    Represents a range of lines in a text, with 0-based start and end indices and indentation.
+
+    This class is used to specify a range of lines in a text, typically for
+    text manipulation operations. It includes methods for comparing ranges,
+    modifying the range, and performing operations on text using the range.
+
+    Attributes:
+        start (int): The starting 0-based index of the range.
+        end (int): The ending 0-based index of the range (exclusive).
+        indent (int): The indentation level at the start of the range.
+    """
     start: int
     end: int
     indent: int = 0
@@ -16,122 +44,256 @@ class RangeSpec(NamedTuple):
     def __str__(self):
         return (f'{self.start}:{self.end}' if self.as_index is None else f'%{self.as_index}') + f'@{self.indent}'
 
-    def __len__(self):
+    def __lt__(self, other):
+        """Compare if this range is strictly before another range or int."""
+        match other:
+            case int():
+                return self.end <= other
+            case RangeSpec():
+                return self.end <= other.start
+
+    def __le__(self, other):
+        """Compare if this range is before or adjacent to another range."""
+        match other:
+            case int():
+                return self.end <= other - 1
+            case RangeSpec():
+                return self.end <= other.start - 1
+
+    def __gt__(self, other):
+        """Compare if this range is strictly after another range."""
+        match other:
+            case int():
+                return self.start > other
+            case RangeSpec():
+                return self.start >= other.end
+
+    def __ge__(self, other):
+        """Compare if this range is after or adjacent to another range."""
+        match other:
+            case int():
+                return self.start >= other
+            case RangeSpec():
+                return self.start >= other.end - 1
+
+    def __contains__(self, item):
+        match item:
+            case int():
+                return self.start <= item < self.end
+            case RangeSpec():
+                return (
+                    self == RangeSpec.EMPTY or
+                    item != RangeSpec.EMPTY and self.start <= item.start and item.end <= self.end
+                )
+
+    @property
+    def line_count(self):
+        """Return the number of lines in the range."""
         return self.end - self.start
 
     @property
     def as_index(self) -> int | None:
-        return None if len(self) else self.start
+        """Return the start index if the range is empty, otherwise None."""
+        return None if self.line_count else self.start
 
     @property
     def collapsed(self):
-        return self.set_length(0)
+        """Return a new RangeSpec with the same start but zero length."""
+        return self.set_line_count(0)
 
-    def set_length(self, range_len: int):
+    def set_line_count(self, range_len: int):
+        """Return a new RangeSpec with the specified line count by adjusting its end."""
         return self._replace(end=self.start + range_len)
 
     def inc(self, count: int = 1):
+        """Return a new RangeSpec shifted forward by the specified count."""
         return self._replace(start=self.start + count, end=self.end + count)
 
     def dec(self, count: int = 1):
+        """Return a new RangeSpec shifted backward by the specified count."""
         return self._replace(start=self.start - count, end=self.end - count)
 
-    def read[S: Sequence[str]](self, src: S) -> S:
+    def read(self, src: Sequence[str]) -> Sequence[str]:
+        """Read and return the lines from the source sequence specified by this range."""
         return src[self.start:self.end]
 
-    def write[S: Sequence[str]](self, src: S, target: S):
-        target[self.start:self.end] = src
+    def write(self, src: Sequence[str], target: Sequence[str]):
+        """Write the source lines into the target sequence at the index position specified by this range."""
+        target[self.start:self.end if self.end >= 0 else len(target)] = src
 
-    def delete[S: Sequence[str]](self, src: S) -> S:
+    def delete(self, src: Sequence[str]) -> Sequence[str]:
+        """Delete the lines specified by this range from the source sequence and return the deleted lines."""
         result = self.read(src)
         del src[self.start:self.end]
         return result
 
     @staticmethod
     def normalize_line(line: str):
+        """Normalize a line by replacing non-word characters with dots and stripping whitespace."""
         return re.sub(r'[^\w]', '.', line.strip(), flags=re.UNICODE)
 
     @classmethod
-    def from_line_marker[T: RangeSpec](
-            cls: T,
+    def from_line_marker(
+            cls,
             lines: Sequence[str],
             search_term: Marker,
             search_range: 'RangeSpec' = None
-    ) -> T | None:
+    ):
         """
         Find the index of a specified line within a list of strings, considering different match types and an offset.
 
-        This function searches for a given line within a list, considering 4 types of matches in order of priority:
+        This method searches for a given line within a list, considering 4 types of matches in order of priority:
         1. Exact match
         2. Stripped match (ignoring leading and trailing whitespace)
         3. Normalized match (ignoring non-alphanumeric characters)
         4. Partial (Searching for a substring, using `casefold` to ignore upper- and lower-case differences).
 
-        The function applies the offset across all match types while maintaining the priority order.
+        The method applies the offset across all match types while maintaining the priority order.
 
-        :Args:
-            :param lines: The list of strings to search through.
-            :param search_term:
-                search_marker.value: The line to search for.
-                search_marker.offset: The number of matches to skip before returning a result.
+        Args:
+            lines (Sequence[str]): The list of strings to search through.
+            search_term (Marker): A Marker object containing:
+                - value: The line to search for.
+                - offset: The number of matches to skip before returning a result.
                           0 skips no match and returns the first match, 1 returns the second match, and so on.
-            :param search_range: The index to start the search from. Defaults to 0. The index to end the search at (exclusive).
-                                  Defaults to (0, -1), which means search to the end of the list.
+            search_range (RangeSpec, optional): The range to search within. Defaults to None, which means
+            search the entire list.
 
-        :returns:
-            RangeSpec: The index for the desired line in the 'lines' list.
-                 Returns None if no match is found or if the offset exceeds the number of matches within each category.
+        Returns:
+            RangeSpec: A RangeSpec object representing the found line, or None if no match is found.
 
-        :Example:
-            >> lines = ["Hello, world!", "  Hello, world!  ", "Héllo, wörld?", "Another line", "Hello, world!"]
-            >> _find_line_index(lines, "Hello, world!", 1)
-            4  # Returns the index of the second exact match
+        Raises:
+            ValueError: If there are multiple matches and no offset is specified, or if the offset exceeds the
+            number of matches.
 
         Note:
-            - The function prioritizes match types in the order: exact, stripped, normalized, partial.
-            - The offset is considered separately for each type.
+            - The method prioritizes match types in the order: exact, stripped, normalized, partial.
+            - The offset is considered separately for each match type.
         """
-        search_start_index, search_end_index, _ = search_range if search_range is not None else (0, -1, 0)
+        search_start_index, search_end_index, _ = search_range if search_range is not None else RangeSpec.EMPTY
         search_line = search_term.value
-        assert search_line, "Empty marker"
         assert search_term.type == MarkerType.LINE, f"Invalid marker type: {search_term.type}"
 
-        matches = {t: [] for t in MATCH_TYPES}
 
-        stripped_search = search_line.strip()
-        normalized_search_line = cls.normalize_line(stripped_search)
+        matches = {t: [] for t in MATCH_TYPES}
 
         if search_start_index < 0:
             search_start_index = 0
         if search_end_index < 0:
             search_end_index = len(lines)
 
-        assert search_start_index < len(lines), f"search start index ({search_start_index}) must be less than line count ({len(lines)})"
-        assert search_end_index <= len(lines), f"search end index ({search_end_index}) must be less than or equal to line count ({len(lines)})"
-
-        for i in range(search_start_index, search_end_index):
-            line = lines[i]
-            reference_indent = get_line_indent_count(line)
-
-            # Check for exact match
-            if search_line == line:
-                matches['exact'].append((i, reference_indent))
+        assert search_start_index < len(lines), (
+            f"search start index ({search_start_index}) "
+            f"must be less than line count ({len(lines)})"
+        )
+        assert search_end_index <= len(lines), (
+            f"search end index ({search_end_index}) "
+            f"must be less than or equal to line count ({len(lines)})"
+        )
+
+        marker_subtype = (search_term.marker_subtype or "string").casefold()
+        assert search_line is not None or marker_subtype == 'empty', "Empty marker"
+
+        # Handle special marker subtypes that don't use normal line matching
+        match marker_subtype:
+            case 'number':  # Match by line number relative to search range
+                relative_index = search_line - 1
+                if search_range:
+                    # Make index relative to search range start
+                    index = search_range.start + relative_index
+                    if not (0 <= index <= len(lines)):
+                        raise ValueError(
+                            f"Line number {search_line} out of bounds "
+                            f"(must be in interval [1, {len(lines) + 1}] relative to context)"
+                        )
+                else:
+                    index = relative_index
+                    if not (0 <= index < len(lines)):
+                        raise ValueError(
+                            f"Line number {search_line} out of bounds "
+                            f"(must be in interval [1, {len(lines)}])"
+                        )
+                reference_indent = get_line_indent_count_from_lines(lines, index)
+                index += calc_index_delta_for_relative_position(search_term)
+                return cls(index, index, reference_indent)
 
-            # Check for stripped match
-            elif stripped_search == line.strip():
-                matches['stripped'].append((i, reference_indent))
+            case 'regex':  # Match using regex pattern
+                try:
+                    pattern = re.compile(search_line)
+                except re.error as e:
+                    raise ValueError(f"Invalid regex pattern '{search_line}': {e}")
 
-            # Check for normalized match
-            elif normalized_search_line == cls.normalize_line(line):
-                matches['normalized'].append((i, reference_indent))
+            case _:  # Default string matching modes
+                pattern = None
+                stripped_search = search_line.strip() if search_line else ""
+                normalized_search_line = cls.normalize_line(stripped_search)
 
-            # Last resort!
-            elif normalized_search_line.casefold() in cls.normalize_line(line).casefold():
-                matches['partial'].append((i, reference_indent))
+        # Find all matching lines based on marker subtype
+        for i in range(search_start_index, search_end_index):
+            reference_indent = get_line_indent_count_from_lines(lines, i)
+            line = lines[i]
+            stripped_line = line.strip()
+            normalized_line = cls.normalize_line(line)
+
+            match marker_subtype:
+                case 'empty':
+                    if not line or not stripped_line:
+                        matches['stripped'].append((i, reference_indent))
+
+                case 'indent-level':
+                    if reference_indent == search_line: # TODO Calc indent level
+                        matches['exact'].append((i, reference_indent))
+
+                case 'regex':
+                    if pattern.search(line) or pattern.search(stripped_line):
+                        matches['exact'].append((i, reference_indent))
+
+                case 'prefix':
+                    if stripped_line.startswith(stripped_search):
+                        matches['exact'].append((i, reference_indent))
+                    elif normalized_line.startswith(normalized_search_line):
+                        matches['normalized'].append((i, reference_indent))
+
+                case 'suffix':
+                    if stripped_line.endswith(stripped_search):
+                        matches['exact'].append((i, reference_indent))
+                    elif normalized_line.endswith(normalized_search_line):
+                        matches['normalized'].append((i, reference_indent))
+
+                case 'string' | _:  # Default string matching
+                    if search_line == line:
+                        matches['exact'].append((i, reference_indent))
+                    elif stripped_search == stripped_line:
+                        matches['stripped'].append((i, reference_indent))
+                    elif normalized_search_line == normalized_line:
+                        matches['normalized'].append((i, reference_indent))
+                    elif normalized_search_line.casefold() in normalized_line.casefold():
+                        matches['partial'].append((i, reference_indent))
 
         offset = search_term.offset or 0
+        max_match_count = max([len(m) for m in matches.values()])
         for match_type in MATCH_TYPES:
-            if offset < len(matches[match_type]):
+            match_type_count = len(matches[match_type])
+            if search_term.offset is None and match_type_count > 1:
+                raise ValueError(
+                    f"Line marker `{search_term.value}` is ambiguous (found {match_type_count} lines matching it) "
+                    "Suggestions: 1) To disambiguate, try using a *different line* as marker (a couple lines before or "
+                    "after the one you tried); 2) If you wanted to *REPLACE* line, try instead to replace a *SEGMENT* "
+                    "a couple of lines long."
+                    # f"Add an `OFFSET` (after the line marker) and a number between 0 and {match_type_count - 1}
+                    # to determine how many to skip. "
+                    # f"Example to reference the *last* one of those:
+                    # `LINE '{search_term.value.strip()}' OFFSET {match_type_count - 1}`"
+                    # ' (See `offset_clause` in `<grammar.js>` for details on OFFSET)'
+                )
+
+            if match_type_count and offset >= max_match_count:
+                raise ValueError(
+                    f"There are only {max_match_count} lines matching `{search_term.value}`, "
+                    f"but 'OFFSET' was set to {search_term.offset} (you can skip at most {match_type_count-1} of those)"
+                )
+
+            if offset < match_type_count:
                 index, reference_indent = matches[match_type][offset]
                 match match_type:
                     case 'normalized':
@@ -139,16 +301,7 @@ def from_line_marker[T: RangeSpec](
                     case 'partial':
                         print(f"Note: Won't accept {match_type} match at index {index} for {search_term}")
                         continue
-                if isinstance(search_term, RelativeMarker):
-                    match search_term.qualifier:
-                        case RelativePositionType.BEFORE:
-                            index += -1
-                        case RelativePositionType.AFTER:
-                            index += 1
-                        case RelativePositionType.AT:
-                            pass
-                        case _ as invalid:
-                            raise ValueError(f"Not implemented: {invalid}")
+                index += calc_index_delta_for_relative_position(search_term)
                 return cls(index, index, reference_indent)
 
         return None
@@ -157,27 +310,111 @@ def from_line_marker[T: RangeSpec](
 RangeSpec.EMPTY = RangeSpec(0, -1, 0)
 
 
-class IdentifierBoundaries(NamedTuple):
+def calc_index_delta_for_relative_position(marker: Marker):
+    match marker:
+        case RelativeMarker(qualifier=RelativePositionType.BEFORE):
+            return -1
+        case RelativeMarker(qualifier=RelativePositionType.AFTER):
+            return 1
+        case RelativeMarker(qualifier=RelativePositionType.AT):
+            pass
+        case RelativeMarker(qualifier=invalid):
+            raise ValueError(f"Not implemented: {invalid}")
+        case _:
+            pass
+    return 0
+
+
+class ParentInfo(NamedTuple):
+    parent_name: str
+    parent_type: str
+
+
+ParentRestriction: TypeAlias = RangeSpec | str | None
+
+@dataclass
+class IdentifierBoundaries:
+    """
+    Represents the boundaries of an identifier in code, including its whole range and body range.
+
+    This class is used to specify the range of an entire identifier (whole) and its body,
+    which is typically the content inside the identifier's definition.
+
+    Attributes:
+        whole (RangeSpec): The RangeSpec representing the entire identifier.
+        body (RangeSpec): The RangeSpec representing the body of the identifier.
+    """
+
     whole: RangeSpec
-    body: RangeSpec
+    body: RangeSpec | None = None
+    docstring: RangeSpec | None = None
+    decorators: list[RangeSpec] = field(default_factory=list)
+    parents: list[ParentInfo] = field(default_factory=list)
+
+    def append_decorator(self, decorator: RangeSpec):
+        self.decorators.append(decorator)
+        self.whole = self.whole._replace(start = min(self.whole.start, decorator.start))
 
     def __str__(self):
         return f'IdentifierBoundaries({self.whole} (BODY: {self.body}) )'
 
     @property
     def start_line(self) -> int:
+        """Return the 1-indexed start line of the whole identifier."""
         return self.whole.start + 1
 
     @property
     def body_start_line(self) -> int:
-        return self.body.start + 1
+        """Return the 1-indexed start line of the identifier's body."""
+        return self.body.start + 1 if self.body else None
 
     @property
     def end_line(self) -> int:
+        """Return the 1-indexed end line of the whole identifier."""
         return self.whole.end
 
-    # See the other bow_to_search_range
+    def match_parent(self, parent_restriction: ParentRestriction) -> bool:
+        match parent_restriction:
+            case None:
+                return True
+            case RangeSpec():
+                return self.whole in parent_restriction
+            case str() as parent_spec:
+                # Parent chain matching: Handle dot notation for parent relationships
+                parent_chain = parent_spec.split('.')
+                if len(parent_chain) == 1:
+                    # Simple case - just check if name is any of the parents
+                    return parent_spec in [p.parent_name for p in self.parents]
+                parent_chain = [p for p in parent_chain if p]
+                if len(parent_chain) > len(self.parents):
+                    return False
+                # len(parent_chain) <= len(self.parents)
+                # Check parent chain partially matches (
+                #   sub-chain match when there are fewer items in 'parent_chain' than in 'self.parents'
+                # )
+                return all(
+                    expected == actual.parent_name
+                    for expected, actual in zip(parent_chain, self.parents)
+                )
+            case _:
+                raise ValueError(f'Invalid parent restriction: {parent_restriction}')
+
     def location_to_search_range(self, location: BodyOrWhole | RelativePositionType) -> RangeSpec:
+        """
+        Convert a location specifier to a RangeSpec for searching.
+
+        This method interprets various location specifiers and returns the appropriate
+        RangeSpec for searching within or around the identifier.
+
+        Args:
+            location (BodyOrWhole | RelativePositionType): The location specifier.
+
+        Returns:
+            RangeSpec: The corresponding RangeSpec for the specified location.
+
+        Raises:
+            ValueError: If an invalid location specifier is provided.
+        """
         match location:
             case BodyOrWhole.BODY:
                 return self.body
@@ -187,9 +424,9 @@ def location_to_search_range(self, location: BodyOrWhole | RelativePositionType)
                 return RangeSpec(self.whole.start, self.whole.start, self.whole.indent)
             case RelativePositionType.AFTER:
                 return RangeSpec(self.whole.end, self.whole.end, self.whole.indent)
-            case RelativePositionType.INSIDE_TOP:
+            case RelativePositionType.INTO_TOP:
                 return RangeSpec(self.body.start, self.body.start, self.body.indent)
-            case RelativePositionType.INSIDE_BOTTOM:
+            case RelativePositionType.INTO_BOTTOM:
                 return RangeSpec(self.body.end, self.body.end, self.body.indent)
             case _ as invalid:
                 raise ValueError(f"Invalid: {invalid}")
diff --git a/src/text_manipulation/text_editor_kit.py b/src/text_manipulation/text_editor_kit.py
index 29e43f3..0c0a342 100644
--- a/src/text_manipulation/text_editor_kit.py
+++ b/src/text_manipulation/text_editor_kit.py
@@ -1,17 +1,42 @@
+"""
+This module provides utilities for text editing operations, particularly focused on
+working with markers, segments, and range specifications in source code.
+
+It includes functions for file I/O, marker and segment processing, and range
+manipulations, which are useful for tasks such as code analysis and transformation.
+"""
+
 from collections.abc import Sequence
 from typing import Protocol, runtime_checkable
+from os import PathLike, path
 
 from cedarscript_ast_parser import Marker, RelativeMarker, RelativePositionType, Segment, MarkerType, BodyOrWhole
-from text_manipulation.range_spec import IdentifierBoundaries, RangeSpec
+from .range_spec import IdentifierBoundaries, RangeSpec
+
 
+def read_file(file_path: str | PathLike) -> str:
+    """
+    Read the contents of a file.
 
-def read_file(file_path: str) -> str:
-    with open(file_path, 'r') as file:
+    Args:
+        file_path (str | PathLike): The path to the file to be read.
+
+    Returns:
+        str: The contents of the file as a string.
+    """
+    with open(path.normpath(file_path), 'r') as file:
         return file.read()
 
 
-def write_file(file_path: str, lines: Sequence[str]):
-    with open(file_path, 'w') as file:
+def write_file(file_path: str | PathLike, lines: Sequence[str]):
+    """
+    Write a sequence of lines to a file.
+
+    Args:
+        file_path (str | PathLike): The path to the file to be written.
+        lines (Sequence[str]): The lines to be written to the file.
+    """
+    with open(path.normpath(file_path), 'w') as file:
         file.writelines([line + '\n' for line in lines])
 
 
@@ -19,6 +44,19 @@ def write_file(file_path: str, lines: Sequence[str]):
 #     return len(line) - len(line.lstrip(char))
 
 def bow_to_search_range(bow: BodyOrWhole, searh_range: IdentifierBoundaries | RangeSpec | None = None) -> RangeSpec:
+    """
+    Convert a BodyOrWhole specification to a search range.
+
+    Args:
+        bow (BodyOrWhole): The BodyOrWhole specification.
+        searh_range (IdentifierBoundaries | RangeSpec | None, optional): The search range to use. Defaults to None.
+
+    Returns:
+        RangeSpec: The resulting search range.
+
+    Raises:
+        ValueError: If an invalid search range is provided.
+    """
     match searh_range:
 
         case RangeSpec() | None:
@@ -40,11 +78,29 @@ def bow_to_search_range(bow: BodyOrWhole, searh_range: IdentifierBoundaries | Ra
 
 @runtime_checkable
 class MarkerOrSegmentProtocol(Protocol):
+    """
+    A protocol for objects that can be converted to an index range.
+
+    This protocol defines the interface for objects that can be converted
+    to a RangeSpec based on a sequence of lines and search indices.
+    """
+
     def marker_or_segment_to_index_range(
         self,
         lines: Sequence[str],
         search_start_index: int = 0, search_end_index: int = -1
     ) -> RangeSpec:
+        """
+        Convert the object to an index range.
+
+        Args:
+            lines (Sequence[str]): The lines to search in.
+            search_start_index (int, optional): The start index for the search. Defaults to 0.
+            search_end_index (int, optional): The end index for the search. Defaults to -1.
+
+        Returns:
+            RangeSpec: The resulting index range.
+        """
         ...
 
 
@@ -53,10 +109,30 @@ def marker_or_segment_to_search_range_impl(
     lines: Sequence[str],
     search_range: RangeSpec = RangeSpec.EMPTY
 ) -> RangeSpec | None:
+    """
+    Implementation of the marker or segment to search range conversion.
+
+    This function is used to convert a Marker or Segment object to a RangeSpec.
+
+    Args:
+        self: The Marker or Segment object.
+        lines (Sequence[str]): The lines to search in.
+        search_range (RangeSpec, optional): The initial search range. Defaults to RangeSpec.EMPTY.
+
+    Returns:
+        RangeSpec | None: The resulting search range, or None if not found.
+
+    Raises:
+        ValueError: If an unexpected type is encountered.
+    """
     match self:
         case Marker(type=MarkerType.LINE):
             result = RangeSpec.from_line_marker(lines, self, search_range)
-            assert result is not None, f"Unable to find `{self}`; Try: 1) Double-checking the marker (maybe you specified the the wrong one); or 2) using *exactly* the same characters from source; or 3) using another marker"
+            assert result is not None, (
+                f"Unable to find {self}; Try: 1) Double-checking the marker "
+                f"(maybe you specified the the wrong one); or 2) using *exactly* the same characters from source; "
+                f"or 3) using another marker"
+            )
             # TODO check under which circumstances we should return a 1-line range instead of an empty range
             return result
         case Segment(start=s, end=e):
@@ -74,16 +150,53 @@ def segment_to_search_range(
         start_relpos: RelativeMarker, end_relpos: RelativeMarker,
         search_range: RangeSpec = RangeSpec.EMPTY
 ) -> RangeSpec:
+    """
+    Convert a segment defined by start and end relative markers to a search range.
+
+    This function takes a segment defined by start and end relative markers and
+    converts it to a RangeSpec that can be used for searching within the given lines.
+
+    Args:
+        lines (Sequence[str]): The lines to search in.
+        start_relpos (RelativeMarker): The relative marker for the start of the segment.
+        end_relpos (RelativeMarker): The relative marker for the end of the segment.
+        search_range (RangeSpec, optional): The initial search range. Defaults to RangeSpec.EMPTY.
+
+    Returns:
+        RangeSpec: The resulting search range.
+
+    Raises:
+        AssertionError: If the lines are empty or if the start or end markers cannot be found.
+    """
     assert len(lines), "`lines` is empty"
 
+    match search_range:
+        case None:
+            search_range = RangeSpec.EMPTY
     start_match_result = RangeSpec.from_line_marker(lines, start_relpos, search_range)
-    assert start_match_result, f"Unable to find segment start `{start_relpos}`; Try: 1) Double-checking the marker (maybe you specified the the wrong one); or 2) using *exactly* the same characters from source; or 3) using a marker from above"
+    assert start_match_result, (
+        f"Unable to find segment start: {start_relpos}; Try: "
+        f"1) Double-checking the marker (maybe you specified the the wrong one); or "
+        f"2) Using *exactly* the same characters from source; or 3) using a marker from above"
+    )
 
     start_index_for_end_marker = start_match_result.as_index
-    if start_relpos.qualifier == RelativePositionType.AFTER:
-        start_index_for_end_marker += -1
-    end_match_result = RangeSpec.from_line_marker(lines, end_relpos, RangeSpec(start_index_for_end_marker, search_range.end, start_match_result.indent))
-    assert end_match_result, f"Unable to find segment end `{end_relpos}` - Try: 1) using *exactly* the same characters from source; or 2) using a marker from below"
+    search_range_for_end_marker = search_range
+    if end_relpos.marker_subtype != 'number':
+        match start_relpos:
+            case RelativeMarker(qualifier=RelativePositionType.AFTER):
+                start_index_for_end_marker += -1
+                search_range_for_end_marker = RangeSpec(
+                    start_index_for_end_marker,
+                    search_range.end,
+                    start_match_result.indent
+                )
+    end_match_result = RangeSpec.from_line_marker(lines, end_relpos, search_range_for_end_marker)
+    assert end_match_result, (
+        f"Unable to find segment end: {end_relpos}; Try: "
+        f"1) Using *exactly* the same characters from source; or "
+        f"2) using a marker from below"
+    )
     if end_match_result.as_index > -1:
         one_after_end = end_match_result.as_index + 1
         end_match_result = RangeSpec(one_after_end, one_after_end, end_match_result.indent)
diff --git a/src/tree-sitter-queries/TODO/TODO.txt b/src/tree-sitter-queries/TODO/TODO.txt
new file mode 100644
index 0000000..b298f4d
--- /dev/null
+++ b/src/tree-sitter-queries/TODO/TODO.txt
@@ -0,0 +1,845 @@
+LANG_TO_TREE_SITTER_QUERY = {
+    "php": {
+        'function': """
+    ; Regular function definitions with optional attributes and docstring
+    (function_definition
+      (attribute_list)? @function.decorator
+      name: (name) @function.name
+      body: (compound_statement) @function.body) @function.definition
+
+    (function_definition
+      (attribute_list)? @function.decorator
+      (comment) @function.docstring
+      name: (name) @function.name
+      body: (compound_statement) @function.body) @function.definition
+
+    ; Method definitions in classes with optional attributes and docstring
+    (method_declaration
+      (attribute_list)? @function.decorator
+      name: (name) @function.name
+      body: (compound_statement) @function.body) @function.definition
+
+    (method_declaration
+      (attribute_list)? @function.decorator
+      (comment) @function.docstring
+      name: (name) @function.name
+      body: (compound_statement) @function.body) @function.definition
+
+    ; Anonymous functions
+    (anonymous_function
+      (attribute_list)? @function.decorator
+      body: (compound_statement) @function.body) @function.definition
+
+    ; Arrow functions
+    (arrow_function
+      (attribute_list)? @function.decorator
+      body: (expression) @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    ; Regular class definitions with optional attributes and docstring
+    (class_declaration
+      (attribute_list)? @class.decorator
+      name: (name) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    (class_declaration
+      (attribute_list)? @class.decorator
+      (comment) @class.docstring
+      name: (name) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    ; Interface definitions
+    (interface_declaration
+      (attribute_list)? @class.decorator
+      name: (name) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    (interface_declaration
+      (attribute_list)? @class.decorator
+      (comment) @class.docstring
+      name: (name) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    ; Trait definitions
+    (trait_declaration
+      (attribute_list)? @class.decorator
+      name: (name) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    (trait_declaration
+      (attribute_list)? @class.decorator
+      (comment) @class.docstring
+      name: (name) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    ; Enum definitions
+    (enum_declaration
+      (attribute_list)? @class.decorator
+      name: (name) @class.name
+      body: (enum_declaration_list) @class.body) @class.definition
+
+    (enum_declaration
+      (attribute_list)? @class.decorator
+      (comment) @class.docstring
+      name: (name) @class.name
+      body: (enum_declaration_list) @class.body) @class.definition
+    """)
+    },
+
+    "rust": {
+        'function': """
+    ; Function definitions with optional attributes, visibility, and docstring
+    (function_item
+      (attribute_item)? @function.decorator
+      (visibility_modifier)?
+      (function_modifiers)?
+      "fn"
+      name: (identifier) @function.name
+      parameters: (parameters)
+      return_type: (_)?
+      body: (block) @function.body) @function.definition
+
+    (function_item
+      (attribute_item)? @function.decorator
+      (visibility_modifier)?
+      (function_modifiers)?
+      (line_comment)+ @function.docstring
+      "fn"
+      name: (identifier) @function.name
+      parameters: (parameters)
+      return_type: (_)?
+      body: (block) @function.body) @function.definition
+
+    ; Method definitions in impl blocks
+    (impl_item
+      (attribute_item)? @function.decorator
+      (visibility_modifier)?
+      (function_modifiers)?
+      "fn"
+      name: (identifier) @function.name
+      parameters: (parameters)
+      return_type: (_)?
+      body: (block) @function.body) @function.definition
+
+    (impl_item
+      (attribute_item)? @function.decorator
+      (visibility_modifier)?
+      (function_modifiers)?
+      (line_comment)+ @function.docstring
+      "fn"
+      name: (identifier) @function.name
+      parameters: (parameters)
+      return_type: (_)?
+      body: (block) @function.body) @function.definition
+
+    ; Async function definitions
+    (function_item
+      (attribute_item)? @function.decorator
+      (visibility_modifier)?
+      "async"
+      "fn"
+      name: (identifier) @function.name
+      parameters: (parameters)
+      return_type: (_)?
+      body: (block) @function.body) @function.definition
+
+    ; Const function definitions
+    (function_item
+      (attribute_item)? @function.decorator
+      (visibility_modifier)?
+      "const"
+      "fn"
+      name: (identifier) @function.name
+      parameters: (parameters)
+      return_type: (_)?
+      body: (block) @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    ; Struct definitions with optional attributes, visibility, and docstring
+    (struct_item
+      (attribute_item)? @class.decorator
+      (visibility_modifier)?
+      "struct"
+      name: (type_identifier) @class.name
+      body: (field_declaration_list)? @class.body) @class.definition
+
+    (struct_item
+      (attribute_item)? @class.decorator
+      (visibility_modifier)?
+      (line_comment)+ @class.docstring
+      "struct"
+      name: (type_identifier) @class.name
+      body: (field_declaration_list)? @class.body) @class.definition
+
+    ; Enum definitions
+    (enum_item
+      (attribute_item)? @class.decorator
+      (visibility_modifier)?
+      "enum"
+      name: (type_identifier) @class.name
+      body: (enum_variant_list) @class.body) @class.definition
+
+    (enum_item
+      (attribute_item)? @class.decorator
+      (visibility_modifier)?
+      (line_comment)+ @class.docstring
+      "enum"
+      name: (type_identifier) @class.name
+      body: (enum_variant_list) @class.body) @class.definition
+
+    ; Trait definitions
+    (trait_item
+      (attribute_item)? @class.decorator
+      (visibility_modifier)?
+      "trait"
+      name: (type_identifier) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    (trait_item
+      (attribute_item)? @class.decorator
+      (visibility_modifier)?
+      (line_comment)+ @class.docstring
+      "trait"
+      name: (type_identifier) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    ; Union definitions
+    (union_item
+      (attribute_item)? @class.decorator
+      (visibility_modifier)?
+      "union"
+      name: (type_identifier) @class.name
+      body: (field_declaration_list) @class.body) @class.definition
+
+    (union_item
+      (attribute_item)? @class.decorator
+      (visibility_modifier)?
+      (line_comment)+ @class.docstring
+      "union"
+      name: (type_identifier) @class.name
+      body: (field_declaration_list) @class.body) @class.definition
+    """)
+    },
+
+    "go": {
+        'function': """
+    ; Function declarations with optional docstring
+    (function_declaration
+      (comment)* @function.docstring
+      name: (identifier) @function.name
+      body: (block) @function.body) @function.definition
+
+    ; Method declarations with optional docstring
+    (method_declaration
+      (comment)* @function.docstring
+      name: (field_identifier) @function.name
+      body: (block) @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    ; Struct type definitions with optional docstring
+    (type_declaration
+      (type_spec
+        name: (type_identifier) @class.name
+        type: (struct_type
+          (field_declaration_list) @class.body))) @class.definition
+
+    (type_declaration
+      (comment)* @class.docstring
+      (type_spec
+        name: (type_identifier) @class.name
+        type: (struct_type
+          (field_declaration_list) @class.body))) @class.definition
+
+    ; Interface type definitions with optional docstring
+    (type_declaration
+      (type_spec
+        name: (type_identifier) @class.name
+        type: (interface_type
+          (method_spec_list) @class.body))) @class.definition
+
+    (type_declaration
+      (comment)* @class.docstring
+      (type_spec
+        name: (type_identifier) @class.name
+        type: (interface_type
+          (method_spec_list) @class.body))) @class.definition
+    """)
+    },
+
+    "cpp": {
+        'function': """
+    ; Function definitions
+    (function_definition
+      declarator: (function_declarator
+        declarator: (identifier) @function.name)
+      body: (compound_statement) @function.body) @function.definition
+
+    ; Method definitions
+    (function_definition
+      declarator: (function_declarator
+        declarator: (field_identifier) @function.name)
+      body: (compound_statement) @function.body) @function.definition
+
+    ; Constructor definitions
+    (constructor_or_destructor_definition
+      declarator: (function_declarator
+        declarator: (qualified_identifier
+          name: (identifier) @function.name))
+      body: (compound_statement) @function.body) @function.definition
+
+    ; Destructor definitions
+    (constructor_or_destructor_definition
+      declarator: (function_declarator
+        declarator: (destructor_name
+          (identifier) @function.name))
+      body: (compound_statement) @function.body) @function.definition
+
+    ; Operator overloading definitions
+    (function_definition
+      declarator: (function_declarator
+        declarator: (operator_name) @function.name)
+      body: (compound_statement) @function.body) @function.definition
+
+    ; Lambda expressions
+    (lambda_expression
+      body: (compound_statement) @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    ; Class definitions
+    (class_specifier
+      name: (type_identifier) @class.name
+      body: (field_declaration_list) @class.body) @class.definition
+
+    ; Struct definitions
+    (struct_specifier
+      name: (type_identifier) @class.name
+      body: (field_declaration_list) @class.body) @class.definition
+
+    ; Union definitions
+    (union_specifier
+      name: (type_identifier) @class.name
+      body: (field_declaration_list) @class.body) @class.definition
+
+    ; Enum definitions
+    (enum_specifier
+      name: (type_identifier) @class.name
+      body: (enumerator_list) @class.body) @class.definition
+
+    ; Template class definitions
+    (template_declaration
+      (class_specifier
+        name: (type_identifier) @class.name
+        body: (field_declaration_list) @class.body)) @class.definition
+
+    ; Template struct definitions
+    (template_declaration
+      (struct_specifier
+        name: (type_identifier) @class.name
+        body: (field_declaration_list) @class.body)) @class.definition
+    """)
+    },
+
+    "c": {
+        'function': """
+    ; Function definitions
+    (function_definition
+      declarator: (function_declarator
+        declarator: (identifier) @function.name)
+      body: (compound_statement) @function.body) @function.definition
+
+    ; Function definitions with type qualifiers
+    (function_definition
+      type: (type_qualifier)
+      declarator: (function_declarator
+        declarator: (identifier) @function.name)
+      body: (compound_statement) @function.body) @function.definition
+
+    ; Function declarations (prototypes)
+    (declaration
+      declarator: (function_declarator
+        declarator: (identifier) @function.name)) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    ; Struct definitions
+    (struct_specifier
+      name: (type_identifier) @class.name
+      body: (field_declaration_list) @class.body) @class.definition
+
+    ; Union definitions
+    (union_specifier
+      name: (type_identifier) @class.name
+      body: (field_declaration_list) @class.body) @class.definition
+
+    ; Enum definitions
+    (enum_specifier
+      name: (type_identifier) @class.name
+      body: (enumerator_list) @class.body) @class.definition
+
+    ; Typedef struct definitions
+    (declaration
+      (type_qualifier)*
+      "typedef"
+      (type_qualifier)*
+      (struct_specifier
+        name: (type_identifier) @class.name
+        body: (field_declaration_list) @class.body)
+      (type_identifier)) @class.definition
+
+    ; Typedef union definitions
+    (declaration
+      (type_qualifier)*
+      "typedef"
+      (type_qualifier)*
+      (union_specifier
+        name: (type_identifier) @class.name
+        body: (field_declaration_list) @class.body)
+      (type_identifier)) @class.definition
+
+    ; Typedef enum definitions
+    (declaration
+      (type_qualifier)*
+      "typedef"
+      (type_qualifier)*
+      (enum_specifier
+        name: (type_identifier) @class.name
+        body: (enumerator_list) @class.body)
+      (type_identifier)) @class.definition
+    """)
+    },
+
+    "java": {
+        'function': """
+    ; Method declarations
+    (method_declaration
+      (modifiers)? @function.decorator
+      (_method_header
+        name: (identifier) @function.name)
+      body: (block) @function.body) @function.definition
+
+    ; Compact constructor declarations (for records)
+    (compact_constructor_declaration
+      (modifiers)? @function.decorator
+      name: (identifier) @function.name
+      body: (block) @function.body) @function.definition
+
+    ; Constructor declarations
+    (constructor_declaration
+      (modifiers)? @function.decorator
+      (_constructor_declarator
+        name: (identifier) @function.name)
+      body: (constructor_body) @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    ; Class declarations
+    (class_declaration
+      (modifiers)? @class.decorator
+      "class"
+      name: (identifier) @class.name
+      body: (class_body) @class.body) @class.definition
+
+    ; Interface declarations
+    (interface_declaration
+      (modifiers)? @class.decorator
+      "interface"
+      name: (identifier) @class.name
+      body: (interface_body) @class.body) @class.definition
+
+    ; Enum declarations
+    (enum_declaration
+      (modifiers)? @class.decorator
+      "enum"
+      name: (identifier) @class.name
+      body: (enum_body) @class.body) @class.definition
+
+    ; Record declarations
+    (record_declaration
+      (modifiers)? @class.decorator
+      "record"
+      name: (identifier) @class.name
+      body: (class_body) @class.body) @class.definition
+
+    ; Annotation type declarations
+    (annotation_type_declaration
+      (modifiers)? @class.decorator
+      "@interface"
+      name: (identifier) @class.name
+      body: (annotation_type_body) @class.body) @class.definition
+    """)
+    },
+
+    "javascript": {
+        'function': """
+    ; Function declarations
+    (function_declaration
+      name: (identifier) @function.name
+      body: (statement_block) @function.body) @function.definition
+
+    ; Function expressions
+    (function_expression
+      name: (identifier)? @function.name
+      body: (statement_block) @function.body) @function.definition
+
+    ; Arrow functions
+    (arrow_function
+      body: [(expression) (statement_block)] @function.body) @function.definition
+
+    ; Method definitions
+    (method_definition
+      name: [(property_identifier) (private_property_identifier)] @function.name
+      body: (statement_block) @function.body) @function.definition
+
+    ; Generator functions
+    (generator_function_declaration
+      name: (identifier) @function.name
+      body: (statement_block) @function.body) @function.definition
+
+    (generator_function
+      name: (identifier)? @function.name
+      body: (statement_block) @function.body) @function.definition
+
+    ; Async functions
+    (function_declaration
+      "async"
+      name: (identifier) @function.name
+      body: (statement_block) @function.body) @function.definition
+
+    (function_expression
+      "async"
+      name: (identifier)? @function.name
+      body: (statement_block) @function.body) @function.definition
+
+    (arrow_function
+      "async"
+      body: [(expression) (statement_block)] @function.body) @function.definition
+
+    ; Decorators for class methods
+    (method_definition
+      decorator: (decorator)+ @function.decorator
+      name: [(property_identifier) (private_property_identifier)] @function.name
+      body: (statement_block) @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    ; Class declarations
+    (class_declaration
+      name: (identifier) @class.name
+      body: (class_body) @class.body) @class.definition
+
+    ; Class expressions
+    (class
+      name: (identifier)? @class.name
+      body: (class_body) @class.body) @class.definition
+
+    ; Decorators for classes
+    (class_declaration
+      decorator: (decorator)+ @class.decorator
+      name: (identifier) @class.name
+      body: (class_body) @class.body) @class.definition
+
+    (class
+      decorator: (decorator)+ @class.decorator
+      name: (identifier)? @class.name
+      body: (class_body) @class.body) @class.definition
+    """)
+    },
+
+    "lua": {
+        'function': """
+    ; Function definitions
+    (function_definition
+      "function"
+      (parameter_list) @function.parameters
+      (block) @function.body) @function.definition
+
+    ; Local function definitions
+    (local_function_definition_statement
+      "local" "function"
+      (identifier) @function.name
+      (parameter_list) @function.parameters
+      (block) @function.body) @function.definition
+
+    ; Function definition statements
+    (function_definition_statement
+      "function"
+      (identifier) @function.name
+      (parameter_list) @function.parameters
+      (block) @function.body) @function.definition
+
+    ; Function definition statements with table methods
+    (function_definition_statement
+      "function"
+      ((identifier) @function.name
+       ":" (identifier) @function.method)
+      (parameter_list) @function.parameters
+      (block) @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    ; Lua doesn't have built-in classes, but tables are often used to simulate them
+    ; We'll capture table definitions that might represent "classes"
+    (variable_assignment
+      (variable_list
+        (variable) @class.name)
+      "="
+      (expression_list
+        (table) @class.body)) @class.definition
+    """)
+    },
+
+    "fortran": {
+        'function': """
+    (function
+      (function_statement
+        name: (identifier) @function.name)
+      body: (_) @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    (derived_type_definition
+      (derived_type_statement
+        name: (_type_name) @class.name)
+      body: (_) @class.body) @class.definition
+    """)
+    },
+
+    "scala": {
+        'function': """
+    (function_definition
+      (annotation)* @function.decorator
+      (modifiers)? @function.decorator
+      "def"
+      name: (_identifier) @function.name
+      type_parameters: (type_parameters)?
+      parameters: (parameters)*
+      return_type: ((_type) @function.return_type)?
+      body: [
+        (indented_block) @function.body
+        (block) @function.body
+        (expression) @function.body
+      ]?) @function.definition
+
+    (function_declaration
+      (annotation)* @function.decorator
+      (modifiers)? @function.decorator
+      "def"
+      name: (_identifier) @function.name
+      type_parameters: (type_parameters)?
+      parameters: (parameters)*
+      return_type: ((_type) @function.return_type)?) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    (class_definition
+      (annotation)* @class.decorator
+      (modifiers)? @class.decorator
+      "class"
+      name: (_identifier) @class.name
+      type_parameters: (type_parameters)?
+      parameters: (class_parameters)*
+      (extends_clause)?
+      (derives_clause)?
+      body: (template_body)?) @class.definition
+
+    (object_definition
+      (annotation)* @class.decorator
+      (modifiers)? @class.decorator
+      "object"
+      name: (_identifier) @class.name
+      (extends_clause)?
+      (derives_clause)?
+      body: (template_body)?) @class.definition
+
+    (trait_definition
+      (annotation)* @class.decorator
+      (modifiers)? @class.decorator
+      "trait"
+      name: (_identifier) @class.name
+      type_parameters: (type_parameters)?
+      parameters: (class_parameters)*
+      (extends_clause)?
+      (derives_clause)?
+      body: (template_body)?) @class.definition
+
+    (enum_definition
+      (annotation)* @class.decorator
+      "enum"
+      name: (_identifier) @class.name
+      type_parameters: (type_parameters)?
+      parameters: (class_parameters)*
+      (extends_clause)?
+      (derives_clause)?
+      body: (enum_body)) @class.definition
+    """)
+    },
+
+    "c_sharp": {
+        'function': """
+    ; Method declarations
+    (method_declaration
+      (attribute_list)? @function.decorator
+      (modifier)* @function.decorator
+      type: (_)
+      name: (identifier) @function.name
+      parameters: (parameter_list)
+      body: (block) @function.body) @function.definition
+
+    ; Constructor declarations
+    (constructor_declaration
+      (attribute_list)? @function.decorator
+      (modifier)* @function.decorator
+      name: (identifier) @function.name
+      parameters: (parameter_list)
+      body: (block) @function.body) @function.definition
+
+    ; Destructor declarations
+    (destructor_declaration
+      (attribute_list)? @function.decorator
+      "extern"? @function.decorator
+      "~"
+      name: (identifier) @function.name
+      parameters: (parameter_list)
+      body: (block) @function.body) @function.definition
+
+    ; Operator declarations
+    (operator_declaration
+      (attribute_list)? @function.decorator
+      (modifier)* @function.decorator
+      type: (_)
+      "operator"
+      operator: (_)
+      parameters: (parameter_list)
+      body: (block) @function.body) @function.definition
+
+    ; Conversion operator declarations
+    (conversion_operator_declaration
+      (attribute_list)? @function.decorator
+      (modifier)* @function.decorator
+      ("implicit" | "explicit")
+      "operator"
+      type: (_)
+      parameters: (parameter_list)
+      body: (block) @function.body) @function.definition
+
+    ; Local function statements
+    (local_function_statement
+      (attribute_list)? @function.decorator
+      (modifier)* @function.decorator
+      type: (_)
+      name: (identifier) @function.name
+      parameters: (parameter_list)
+      body: (block) @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    ; Class declarations
+    (class_declaration
+      (attribute_list)? @class.decorator
+      (modifier)* @class.decorator
+      "class"
+      name: (identifier) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    ; Struct declarations
+    (struct_declaration
+      (attribute_list)? @class.decorator
+      (modifier)* @class.decorator
+      "struct"
+      name: (identifier) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    ; Interface declarations
+    (interface_declaration
+      (attribute_list)? @class.decorator
+      (modifier)* @class.decorator
+      "interface"
+      name: (identifier) @class.name
+      body: (declaration_list) @class.body) @class.definition
+
+    ; Enum declarations
+    (enum_declaration
+      (attribute_list)? @class.decorator
+      (modifier)* @class.decorator
+      "enum"
+      name: (identifier) @class.name
+      body: (enum_member_declaration_list) @class.body) @class.definition
+
+    ; Record declarations
+    (record_declaration
+      (attribute_list)? @class.decorator
+      (modifier)* @class.decorator
+      "record"
+      name: (identifier) @class.name
+      body: (declaration_list) @class.body) @class.definition
+    """)
+    },
+
+    "cobol": {
+        'function': """
+    (function_definition
+      (function_division
+        name: (program_name) @function.name)
+      (environment_division)?
+      (data_division)?
+      (procedure_division) @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    (data_division
+      (file_section
+        (file_description
+          name: (WORD) @class.name
+          (record_description_list) @class.body))) @class.definition
+
+    (data_division
+      (working_storage_section
+        (data_description
+          level_number: (level_number)
+          name: (entry_name) @class.name
+          (repeat ($._data_description_clause))* @class.body))) @class.definition
+    """)
+    },
+
+    "matlab": {
+        'function': """
+    (function_definition
+      (function_output)?
+      name: (identifier) @function.name
+      (function_arguments)?
+      (_end_of_line)
+      (arguments_statement)*
+      body: (block)? @function.body) @function.definition
+
+    (function_definition
+      (function_output)?
+      "get." @function.decorator
+      name: (identifier) @function.name
+      (function_arguments)?
+      (_end_of_line)
+      (arguments_statement)*
+      body: (block)? @function.body) @function.definition
+
+    (function_definition
+      (function_output)?
+      "set." @function.decorator
+      name: (identifier) @function.name
+      (function_arguments)?
+      (_end_of_line)
+      (arguments_statement)*
+      body: (block)? @function.body) @function.definition
+    """,
+
+        'class': ("class.definition", "class.name", "class.body", "class.docstring", "class.decorator", """
+    (class_definition
+      (attributes)? @class.decorator
+      name: (identifier) @class.name
+      (superclasses)?
+      (_end_of_line)
+      body: (_ (properties | methods | events | enumeration | ";")*)+ @class.body) @class.definition
+    """)
+    }
\ No newline at end of file
diff --git a/src/tree-sitter-queries/kotlin/basedef.scm b/src/tree-sitter-queries/kotlin/basedef.scm
new file mode 100644
index 0000000..3945591
--- /dev/null
+++ b/src/tree-sitter-queries/kotlin/basedef.scm
@@ -0,0 +1,3 @@
+name: (simple_identifier) @_{type}_name
+(#match? @_{type}_name "^{{name}}$")
+(#set! role name)
diff --git a/src/tree-sitter-queries/kotlin/classes.scm b/src/tree-sitter-queries/kotlin/classes.scm
new file mode 100644
index 0000000..2514261
--- /dev/null
+++ b/src/tree-sitter-queries/kotlin/classes.scm
@@ -0,0 +1,28 @@
+; Regular class definitions with optional annotations and KDoc
+(class_declaration
+  (modifiers (annotation) )* @class.decorator
+  (comment)? @class.docstring
+  ["data" "enum"]? @class.subtype
+  {definition_base}
+  body: (class_body) @class.body
+) @class.definition
+
+; Interface definitions
+(interface_declaration
+  (modifiers (annotation) @class.decorator)*
+  (comment)? @class.docstring
+  name: (type_identifier) @_class_name
+  (#match? @_class_name "^{{name}}$")
+  (#set! role name)
+  body: (class_body) @class.body
+) @class.definition
+
+; Object declarations
+(object_declaration
+  (modifiers (annotation) @class.decorator)*
+  (comment)? @class.docstring
+  name: (type_identifier) @_class_name
+  (#match? @_class_name "^{{name}}$")
+  (#set! role name)
+  body: (class_body) @class.body
+) @class.definition
diff --git a/src/tree-sitter-queries/kotlin/common.scm b/src/tree-sitter-queries/kotlin/common.scm
new file mode 100644
index 0000000..e69de29
diff --git a/src/tree-sitter-queries/kotlin/functions.scm b/src/tree-sitter-queries/kotlin/functions.scm
new file mode 100644
index 0000000..462f3d1
--- /dev/null
+++ b/src/tree-sitter-queries/kotlin/functions.scm
@@ -0,0 +1,21 @@
+(function_declaration
+  (comment)? @function.docstring
+  (modifiers (annotation) )* @function.decorator
+  (receiver_type: (type_reference))? @function.receiver
+  (comment)? @function.docstring
+  {definition_base}
+  (type_parameters: (type_parameters))? @function.type_parameters
+  body: (function_body) @function.body
+) @function.definition
+
+; Constructor definitions
+(constructor_declaration
+  (modifiers (annotation) )* @function.decorator
+  (comment)? @function.docstring
+  body: (function_body) @function.body
+) @function.definition
+
+; Lambda expressions
+(lambda_literal
+  body: (_) @function.body
+) @function.definition
\ No newline at end of file
diff --git a/src/tree-sitter-queries/python/basedef.scm b/src/tree-sitter-queries/python/basedef.scm
new file mode 100644
index 0000000..cd512a5
--- /dev/null
+++ b/src/tree-sitter-queries/python/basedef.scm
@@ -0,0 +1,3 @@
+name: (identifier) @_{type}_name
+(#match? @_{type}_name "^{{name}}$")
+(#set! role name)
diff --git a/src/tree-sitter-queries/python/classes.scm b/src/tree-sitter-queries/python/classes.scm
new file mode 100644
index 0000000..1a8fc49
--- /dev/null
+++ b/src/tree-sitter-queries/python/classes.scm
@@ -0,0 +1,37 @@
+; Class Definitions
+(class_definition
+    {definition_base}
+    {common_body}
+) @class.definition
+
+; Decorated Class Definitions
+(decorated_definition
+    (decorator)+ @class.decorator
+    (class_definition
+        {definition_base}
+        {common_body}
+    ) @class.definition
+)
+
+; Nested Classes
+(class_definition
+    body: (block
+        (class_definition
+            {definition_base}
+            {common_body}
+        ) @class.definition
+    )
+)
+
+; Decorated Nested Classes
+(class_definition
+    body: (block
+        (decorated_definition
+            (decorator)+ @class.decorator
+            (class_definition
+                {definition_base}
+                {common_body}
+            ) @class.definition
+        )
+    )
+)
diff --git a/src/tree-sitter-queries/python/common.scm b/src/tree-sitter-queries/python/common.scm
new file mode 100644
index 0000000..8d196ab
--- /dev/null
+++ b/src/tree-sitter-queries/python/common.scm
@@ -0,0 +1,6 @@
+; Common pattern for body and docstring capture
+body: (block
+    .
+    (expression_statement
+        (string) @{type}.docstring)?
+ ) @{type}.body
diff --git a/src/tree-sitter-queries/python/functions.scm b/src/tree-sitter-queries/python/functions.scm
new file mode 100644
index 0000000..76d1912
--- /dev/null
+++ b/src/tree-sitter-queries/python/functions.scm
@@ -0,0 +1,14 @@
+; Function Definitions
+(function_definition
+    {definition_base}
+    {common_body}
+) @function.definition
+
+; Decorated Function Definitions
+(decorated_definition
+    (decorator)+ @function.decorator
+    (function_definition
+        {definition_base}
+        {common_body}
+    ) @function.definition
+)
diff --git a/src/version/__init__.py b/src/version/__init__.py
new file mode 100644
index 0000000..32495d2
--- /dev/null
+++ b/src/version/__init__.py
@@ -0,0 +1,3 @@
+from ._version import version
+
+__version__ = version
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/corpus/.chat.line-numbers/1.py b/tests/corpus/.chat.line-numbers/1.py
new file mode 100644
index 0000000..fcaab23
--- /dev/null
+++ b/tests/corpus/.chat.line-numbers/1.py
@@ -0,0 +1,115 @@
+# RemovedInDjango50Warning
+# Copyright (c) 2010 Guilherme Gondim. All rights reserved.
+# Copyright (c) 2009 Simon Willison. All rights reserved.
+# Copyright (c) 2002 Drew Perttula. All rights reserved.
+#
+# License:
+#   Python Software Foundation License version 2
+#
+# See the file "LICENSE" for terms & conditions for usage, and a DISCLAIMER OF
+# ALL WARRANTIES.
+#
+# This Baseconv distribution contains no GNU General Public Licensed (GPLed)
+# code so it may be used in proprietary projects just like prior ``baseconv``
+# distributions.
+#
+# All trademarks referenced herein are property of their respective holders.
+#
+
+"""
+Convert numbers from base 10 integers to base X strings and back again.
+
+Sample usage::
+
+  >>> base20 = BaseConverter('0123456789abcdefghij')
+  >>> base20.encode(1234)
+  '31e'
+  >>> base20.decode('31e')
+  1234
+  >>> base20.encode(-1234)
+  '-31e'
+  >>> base20.decode('-31e')
+  -1234
+  >>> base11 = BaseConverter('0123456789-', sign='$')
+  >>> base11.encode(-1234)
+  '$-22'
+  >>> base11.decode('$-22')
+  -1234
+
+"""
+import warnings
+
+from django.utils.deprecation import RemovedInDjango50Warning
+
+warnings.warn(
+    "The django.utils.baseconv module is deprecated.",
+    category=RemovedInDjango50Warning,
+    stacklevel=2,
+)
+
+BASE2_ALPHABET = "01"
+BASE16_ALPHABET = "0123456789ABCDEF"
+BASE56_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
+BASE36_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
+BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+BASE64_ALPHABET = BASE62_ALPHABET + "-_"
+
+
+class BaseConverter:
+    decimal_digits = "0123456789"
+
+    def __init__(self, digits, sign="-"):
+        self.sign = sign
+        self.digits = digits
+        if sign in self.digits:
+            raise ValueError("Sign character found in converter base digits.")
+
+    def __repr__(self):
+        return "<%s: base%s (%s)>" % (
+            self.__class__.__name__,
+            len(self.digits),
+            self.digits,
+        )
+
+    def encode(self, i):
+        neg, value = self.convert(i, self.decimal_digits, self.digits, "-")
+        if neg:
+            return self.sign + value
+        return value
+
+    def decode(self, s):
+        neg, value = self.convert(s, self.digits, self.decimal_digits, self.sign)
+        if neg:
+            value = "-" + value
+        return int(value)
+
+    def convert(self, number, from_digits, to_digits, sign):
+        if str(number)[0] == sign:
+            number = str(number)[1:]
+            neg = 1
+        else:
+            neg = 0
+
+        # make an integer out of the number
+        x = 0
+        for digit in str(number):
+            x = x * len(from_digits) + from_digits.index(digit)
+
+        # create the result in base 'len(to_digits)'
+        if x == 0:
+            res = to_digits[0]
+        else:
+            res = ""
+            while x > 0:
+                digit = x % len(to_digits)
+                res = to_digits[digit] + res
+                x = int(x // len(to_digits))
+        return neg, res
+
+
+base2 = BaseConverter(BASE2_ALPHABET)
+base16 = BaseConverter(BASE16_ALPHABET)
+base36 = BaseConverter(BASE36_ALPHABET)
+base56 = BaseConverter(BASE56_ALPHABET)
+base62 = BaseConverter(BASE62_ALPHABET)
+base64 = BaseConverter(BASE64_ALPHABET, sign="$")
diff --git a/tests/corpus/.chat.line-numbers/chat.xml b/tests/corpus/.chat.line-numbers/chat.xml
new file mode 100644
index 0000000..0d994cc
--- /dev/null
+++ b/tests/corpus/.chat.line-numbers/chat.xml
@@ -0,0 +1,56 @@
+<no-train>
+
+# Refactor BaseConverter.convert
+
+Refactor the `convert` method in the `BaseConverter` class to be a stand alone, top level function.
+Name the new function `convert`, exactly the same name as the existing method.
+Update any existing `self.convert` calls to work with the new `convert` function.
+
+When you updated method `decode` to replace line 2, your content block used "@1:".
+Explain in details why the reference point is the method definition.
+Don't change any code, just give me explanations
+
+When updating call sites, use context-relative line numbers with the method being modified as context
+
+Explain in deep details how you chose line number 3 to update method "encode": where do you start counting line numbers?
+don't write any script, just answer my questions.
+
+Explain in deep details why, when replacing the lines, you provided content with relative indent prefix = 1.
+Just answer my question, don't change any code.
+
+The relative indentation level is relative to the line, not relative to the class BaseConverter. Help me improve the
+instructions I gave you earlier about CEDARScript to make it super clear and avoid this mistake in the future.
+
+
+```CEDARScript
+-- Move the `convert` method from the `BaseConverter` class, placing it at the top level, just before the line where its class starts
+UPDATE CLASS "BaseConverter"
+FROM FILE "1.py"
+MOVE METHOD "convert"
+INSERT BEFORE CLASS "BaseConverter"
+RELATIVE INDENTATION 0;
+
+-- Update the copied function to remove references to `self`
+UPDATE FUNCTION "convert"
+FROM FILE "1.py"
+REPLACE LINE 1
+WITH CONTENT '''
+@0:def convert(number, from_digits, to_digits, sign):
+''';
+
+-- Update ALL call sites of the method `convert` to call the new top-level function with the same name
+UPDATE METHOD "encode"
+FROM FILE "1.py"
+REPLACE LINE 3
+WITH CONTENT '''
+@0:neg, value = convert(i, self.decimal_digits, self.digits, "-")
+''';
+UPDATE METHOD "decode"
+FROM FILE "1.py"
+REPLACE LINE 3
+WITH CONTENT '''
+@0:neg, value = convert(s, self.digits, self.decimal_digits, self.sign)
+''';
+
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/.chat.line-numbers/expected.1.py b/tests/corpus/.chat.line-numbers/expected.1.py
new file mode 100644
index 0000000..1c2dff2
--- /dev/null
+++ b/tests/corpus/.chat.line-numbers/expected.1.py
@@ -0,0 +1,115 @@
+# RemovedInDjango50Warning
+# Copyright (c) 2010 Guilherme Gondim. All rights reserved.
+# Copyright (c) 2009 Simon Willison. All rights reserved.
+# Copyright (c) 2002 Drew Perttula. All rights reserved.
+#
+# License:
+#   Python Software Foundation License version 2
+#
+# See the file "LICENSE" for terms & conditions for usage, and a DISCLAIMER OF
+# ALL WARRANTIES.
+#
+# This Baseconv distribution contains no GNU General Public Licensed (GPLed)
+# code so it may be used in proprietary projects just like prior ``baseconv``
+# distributions.
+#
+# All trademarks referenced herein are property of their respective holders.
+#
+
+"""
+Convert numbers from base 10 integers to base X strings and back again.
+
+Sample usage::
+
+  >>> base20 = BaseConverter('0123456789abcdefghij')
+  >>> base20.encode(1234)
+  '31e'
+  >>> base20.decode('31e')
+  1234
+  >>> base20.encode(-1234)
+  '-31e'
+  >>> base20.decode('-31e')
+  -1234
+  >>> base11 = BaseConverter('0123456789-', sign='$')
+  >>> base11.encode(-1234)
+  '$-22'
+  >>> base11.decode('$-22')
+  -1234
+
+"""
+import warnings
+
+from django.utils.deprecation import RemovedInDjango50Warning
+
+warnings.warn(
+    "The django.utils.baseconv module is deprecated.",
+    category=RemovedInDjango50Warning,
+    stacklevel=2,
+)
+
+BASE2_ALPHABET = "01"
+BASE16_ALPHABET = "0123456789ABCDEF"
+BASE56_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
+BASE36_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
+BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+BASE64_ALPHABET = BASE62_ALPHABET + "-_"
+
+
+def convert(number, from_digits, to_digits, sign):
+    if str(number)[0] == sign:
+        number = str(number)[1:]
+        neg = 1
+    else:
+        neg = 0
+
+    # make an integer out of the number
+    x = 0
+    for digit in str(number):
+        x = x * len(from_digits) + from_digits.index(digit)
+
+    # create the result in base 'len(to_digits)'
+    if x == 0:
+        res = to_digits[0]
+    else:
+        res = ""
+        while x > 0:
+            digit = x % len(to_digits)
+            res = to_digits[digit] + res
+            x = int(x // len(to_digits))
+    return neg, res
+class BaseConverter:
+    decimal_digits = "0123456789"
+
+    def __init__(self, digits, sign="-"):
+        self.sign = sign
+        self.digits = digits
+        if sign in self.digits:
+            raise ValueError("Sign character found in converter base digits.")
+
+    def __repr__(self):
+        return "<%s: base%s (%s)>" % (
+            self.__class__.__name__,
+            len(self.digits),
+            self.digits,
+        )
+
+    def encode(self, i):
+        neg, value = self.convert(i, self.decimal_digits, self.digits, "-")
+        if neg:
+            return self.sign + value
+        return value
+
+    def decode(self, s):
+        neg, value = self.convert(s, self.digits, self.decimal_digits, self.sign)
+        if neg:
+            value = "-" + value
+        return int(value)
+
+
+
+base2 = BaseConverter(BASE2_ALPHABET)
+base16 = BaseConverter(BASE16_ALPHABET)
+base36 = BaseConverter(BASE36_ALPHABET)
+base56 = BaseConverter(BASE56_ALPHABET)
+base62 = BaseConverter(BASE62_ALPHABET)
+base64 = BaseConverter(BASE64_ALPHABET, sign="$")
diff --git a/tests/corpus/.day-to-day/README.md b/tests/corpus/.day-to-day/README.md
new file mode 100644
index 0000000..343b8ed
--- /dev/null
+++ b/tests/corpus/.day-to-day/README.md
@@ -0,0 +1,20 @@
+
+UPDATE FUNCTION "my_func"                                                                                                                                              
+FROM FILE "1.py"                                                                                                                      
+REPLACE WHOLE WITH CASE                                                                                                                                                 
+WHEN REGEX r'''# Some comment''' THEN SUB                                                                                                        
+r'''# Some comment\n.*?something = find\[search\["col1"\]\.isna\(\)\]'''                                                  
+r'''# Some comment (and then some)                                                                                          
+elements_type_2 = search[search["something"].isna()]                                                                                            
+elements_type_1 = elements_type_2[elements_type_2["req"].isna()]'''                                                           
+END;                                      
+
+instead,:
+
+UPDATE FUNCTION "my_func"                                                                                                                                              
+FROM FILE "1.py"                                                                                                                      
+REPLACE LINE REGEX r'''# Some comment''' WITH CONTENTS '''
+# Some comment (and then some)                                                                                          
+elements_type_2 = search[search["something"].isna()]                                                                                            
+elements_type_1 = elements_type_2[elements_type_2["req"].isna()]'''                                                           
+''';
diff --git a/tests/corpus/.refactoring-benchmark/chat.xml b/tests/corpus/.refactoring-benchmark/chat.xml
new file mode 100644
index 0000000..9418895
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/chat.xml
@@ -0,0 +1,316 @@
+<report>
+
+# CEDARScript 1.1.2 + 1.1.6
+# gemini-1.5-flash-latest
+# pass_rate_1: 74.2
+# pass_rate_2: 75.3
+# pass_rate_3: 75.3
+# percent_cases_well_formed: 89.9
+# total_cost: 1.2366
+
+# CEDARScript 1.1.3 + 1.1.6
+# gemini-1.5-flash-latest
+# pass_rate_1: 74.2
+# pass_rate_2: 75.3
+# pass_rate_3: 75.3
+# percent_cases_well_formed: 94.4
+# total_cost: 1.2109
+
+@@ Worsened, now FAILED (5) @@
+The class identifier named `FxGraphDrawer`: 4 matches
+-- [0 -> -3] (        ------------) (++++++++++++++++++++) graph_drawer_FxGraphDrawer__stringify_tensor_meta
+Moved method now at 2-char indent:
+-- [0 -> -3] (                ----) (++++++++++++++++++++) gradient_checker_GradientChecker__assertInferTensorChecks
+-- [0 -> -3] (        ------------) (++++++++++++++++++++) i18n_JavaScriptCatalog_get_paths
+
+-- [0 -> -3] (         -----------) (++++++++++++++++++++) base_BaseHandler_check_response
+-- [0 -> -3] (  ------------------) (++++++++++++++++++++) group_batch_fusion_GroupLinearFusion_fuse
+
+@@ Stable: FAILED (17) @@
+=- [-4 -> -3] (     ---------------) (++++++++++++++++++  ) builtin_BuiltinVariable_call_setattr
+POSTPONED =- [-4 -> -3] ( -------------------) (++++++++++++++++++++) codeeditor_CodeEditor___get_brackets
+=- [-4 -> -3] (        ------------) (++++++++++++++++++++) common_methods_invocations_foreach_inputs_sample_func__sample_rightmost_arg
+=- [-4 -> -3] (  ------------------) (++++++++++++++++++++) common_utils_TestCase_genSparseTensor
+=- [-4 -> -3] (   -----------------) (++++++++++++++      ) dataframeeditor_DataFrameView_next_index_name
+=- [-4 -> -3] ( -------------------) (++++++++++++++++++++) doc_DocCLI_display_plugin_list
+=- [-4 -> -3] ( -------------------) (+++++++++           ) doc_DocCLI_get_role_man_text
+=- [-4 -> -3] ( -------------------) (++++++++++++++++++++) figure_FigureBase_colorbar
+=- [-4 -> -3] ( -------------------) (+++++++++++++++     ) galaxy_GalaxyCLI_execute_list_collection
+=- [-4 -> -3] (   -----------------) (++++++++++++++++++++) generator_GenOpTestCase_out_variant_op_test_case_generator
+=- [-4 -> -3] ( -------------------) (+++++++++++         ) kernel_SpyderKernel_get_fault_text
+=- [-4 -> -3] ( -------------------) (++++                ) main_widget_PylintWidget_parse_output
+=- [-4 -> -3] ( -------------------) (+++                 ) patches__Curve__get_arrow_wedge
+=- [-4 -> -3] (    ----------------) (+++++++             ) polar_RadialTick__determine_anchor
+=- [-4 -> -3] (  ------------------) (++++++++++++++++++++) sharding_policies_MaxShardSizePolicy__add_partition
+=- [-4 -> -3] ( -------------------) (++++++++++++++++++++) symbolic_shapes_ShapeEnv_bind_symbols
+=- [-4 -> -3] (   -----------------) (+++++++++++++       ) text_CountVectorizer__limit_features
+
+    --
+
+- dirname: 2024-11-04-06-01-55--gemini-1.5-flash-refactoring-cedarscript-i1.1.3-e1.1.7
+test_cases: 89
+model: gemini/gemini-1.5-flash-latest
+edit_format: cedarscript
+commit_hash: 82ac553
+pass_rate_1: 84.3
+pass_rate_2: 84.3
+pass_rate_3: 85.4
+percent_cases_well_formed: 93.3
+error_outputs: 134
+num_malformed_responses: 28
+num_with_malformed_responses: 6
+user_asks: 35
+lazy_comments: 0
+syntax_errors: 6
+indentation_errors: 3
+exhausted_context_windows: 0
+test_timeouts: 0
+command: aider --model gemini/gemini-1.5-flash-latest
+date: 2024-11-04
+versions: 0.60.2.dev
+seconds_per_case: 17.1
+total_cost: 0.6229
+
+</report>
+
+<no-train>
+```CEDARScript
+
+/* makemessages.py
+#### # Refactor Command.add_arguments
+####
+#### Refactor the `add_arguments` method in the `Command` class to be a stand alone, top level function.
+#### Name the new function `add_arguments`, exactly the same name as the existing method.
+#### Update any existing `self.add_arguments` calls to work with the new `add_arguments` function.
+*/
+
+-- 1. Move the `add_arguments` method from the `Command` class, placing it at the top level, just before the line where its class starts
+UPDATE CLASS "Command"
+FROM FILE "makemessages.py"
+MOVE METHOD "add_arguments"
+INSERT BEFORE CLASS "Command"
+RELATIVE INDENTATION 0;
+
+-- 2. Update the copied function to remove references to `self`
+UPDATE FUNCTION "add_arguments"
+FROM FILE "makemessages.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''def add_arguments\(''' THEN SUB
+r'''def add_arguments\(self,'''
+r'''def add_arguments(parser)'''
+END;
+
+-- 3. Update ALL call sites of the method `add_arguments` to call the new top-level function with the same name
+UPDATE CLASS "Command"
+FROM FILE "makemessages.py"
+REPLACE BODY WITH CASE
+WHEN REGEX r'''self\.add_arguments\(''' THEN SUB
+r'''self\.(add_arguments\()'''
+r'''\1'''
+END;
+```
+
+/* config.py
+Doubled fun signature:
+-- [0 -> -4] (        ------------) (++++++++++++++++++++) config_NetworkConfig_parse
+*/
+
+-- 1. Move the `parse` method from the `NetworkConfig` class to the top level of the file.
+UPDATE CLASS "NetworkConfig"
+FROM FILE "config.py"
+MOVE METHOD "parse"
+INSERT BEFORE CLASS "NetworkConfig"
+RELATIVE INDENTATION 0;
+
+-- 2. Update the function signature of the new `parse` function to remove the `self` parameter.
+UPDATE FUNCTION "parse"
+FROM FILE "config.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''def parse''' THEN SUB
+r'''(def parse\()self,\s*'''
+r'''\1lines, comment_tokens=None):
+'''
+END;
+
+-- 3. Update all call sites of `self.parse` to call the new `parse` function.
+UPDATE METHOD "NetworkConfig.load"
+FROM FILE "config.py"
+REPLACE BODY WITH CASE
+WHEN REGEX r'''self\.parse''' THEN SUB
+r'''self\.parse'''
+r'''parse'''
+END;
+
+
+/* generic_bsd.py
+
+# Refactor GenericBsdIfconfigNetwork.parse_inet_line
+Refactor the `parse_inet_line` method in the `GenericBsdIfconfigNetwork` class to be a stand alone, top level function.
+Name the new function `parse_inet_line`, exactly the same name as the existing method.
+Update any existing `self.parse_inet_line` calls to work with the new `parse_inet_line` function.
+
+    > SyntaxError: duplicate argument 'current_if' in function definition:
+    -- [0 -> -4] (                   -) (++++++++++++++++++++) generic_bsd_GenericBsdIfconfigNetwork_parse_inet_line
+*/
+
+-- 1. Move the `parse_inet_line` method from the `GenericBsdIfconfigNetwork` class to the top level of the file.
+-- As method `parse_inet_line` only exists in one place, it's unambiguous, but this time we wmust try the alternative form `UPDATE CLASS..MOVE FUNCTION` (instead of `UPDATE FUNCTION..MOVE WHOLE`) because the latter failed last time we tried.
+UPDATE CLASS "GenericBsdIfconfigNetwork"
+FROM FILE "generic_bsd.py"
+MOVE METHOD "parse_inet_line"
+INSERT BEFORE CLASS "GenericBsdIfconfigNetwork"
+RELATIVE INDENTATION 0;
+
+-- 2. Update the copied function to remove references to `self`
+UPDATE FUNCTION "parse_inet_line"
+FROM FILE "generic_bsd.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''def parse_inet_line''' THEN SUB
+r'''(def parse_inet_line\()self,\s*'''
+r'''\1words, current_if, ips'''
+END;
+
+-- 3. Update ALL call sites of the method `parse_inet_line` to call the new top-level function with the same name
+UPDATE CLASS "GenericBsdIfconfigNetwork"
+FROM FILE "generic_bsd.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''self\.parse_inet_line''' THEN SUB
+r'''self\.parse_inet_line'''
+r'''parse_inet_line'''
+END;
+
+
+/* graph_drawer
+The class identifier named `FxGraphDrawer`: 4 matches
+-- [0 -> -4] (--------------------) (++++++++++++++++++++) graph_drawer_FxGraphDrawer__stringify_tensor_meta
+*/
+-- Ambiguous ?!?!?!
+UPDATE CLASS "FxGraphDrawer" OFFSET 3
+FROM FILE "graph_drawer.py"
+MOVE METHOD "_stringify_tensor_meta"
+INSERT BEFORE CLASS "FxGraphDrawer" OFFSET 3
+RELATIVE INDENTATION 0;
+
+-- 1. Move the `_stringify_tensor_meta` method from the `FxGraphDrawer` class to the top level of the file.
+UPDATE CLASS "FxGraphDrawer"
+FROM FILE "graph_drawer.py"
+MOVE METHOD "_stringify_tensor_meta"
+INSERT BEFORE CLASS "FxGraphDrawer"
+RELATIVE INDENTATION 0;
+
+-- 2. Update the copied function to remove references to `self`
+UPDATE FUNCTION "_stringify_tensor_meta"
+FROM FILE "graph_drawer.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''def _stringify_tensor_meta''' THEN SUB
+r'''(def _stringify_tensor_meta\()self,\s*'''
+r'''\1tm: TensorMetadata'''
+END;
+
+-- 3. Update all call sites of the method `_stringify_tensor_meta` to call the new top-level function with the same name.
+UPDATE CLASS "FxGraphDrawer"
+FROM FILE "graph_drawer.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''self\._stringify_tensor_meta''' THEN SUB
+r'''self\.(_stringify_tensor_meta)'''
+r'''\1'''
+END;
+
+/* migrate.py
+'NoneType' object has no attribute 'line_count':
+-- [0 -> -4] (                  --) (++++++++++++++++++++) migrate_Command_add_arguments
+*/
+-- 1. Move the `add_arguments` method from the `Command` class to the top level of the file.
+UPDATE CLASS "Command"
+FROM FILE "migrate.py"
+MOVE METHOD "add_arguments"
+INSERT BEFORE CLASS "Command"
+RELATIVE INDENTATION 0;
+
+-- 2. Update the copied function to remove references to `self`.
+UPDATE FUNCTION "add_arguments"
+FROM FILE "migrate.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''def add_arguments''' THEN SUB
+r'''(def add_arguments\()self,\s*'''
+r'''\1parser: ArgumentParser, '''
+END;
+
+-- 3. Update all call sites of the method `add_arguments` to call the new top-level function with the same name.
+UPDATE METHOD "Command.handle"
+FROM FILE "migrate.py"
+REPLACE BODY WITH CASE
+WHEN REGEX r'''self\.add_arguments''' THEN SUB
+r'''self\.(add_arguments)'''
+r'''\1'''
+END;
+
+/* operations.py
+    > <error-location>COMMAND #3</error-location>
+    > <description>'class DatabaseOperations [operations.py]' not found</description>
+*/
+-- 1. Move the `bulk_insert_sql` method from the `DatabaseOperations` class to the top level of the file.
+UPDATE CLASS "DatabaseOperations"
+FROM FILE "operations.py"
+MOVE METHOD "bulk_insert_sql"
+INSERT BEFORE CLASS "DatabaseOperations"
+RELATIVE INDENTATION 0;
+
+-- 2. Update the copied function to remove references to `self`.
+UPDATE FUNCTION "bulk_insert_sql"
+FROM FILE "operations.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''def bulk_insert_sql''' THEN SUB
+r'''(def bulk_insert_sql\()self,\s*'''
+r'''\1fields, placeholder_rows):
+'''
+END;
+
+-- 3. Update all call sites of the method `bulk_insert_sql` to call the new top-level function with the same name.
+UPDATE CLASS "DatabaseOperations"
+FROM FILE "operations.py"
+REPLACE BODY WITH CASE
+WHEN REGEX r'''self\.bulk_insert_sql''' THEN SUB
+r'''self\.(bulk_insert_sql)'''
+r'''\1'''
+END;
+
+/* special.py
+-- [0 -> -4] (--------------------) (++++++++++++++++++++) special_RunSQL__run_sql
+> special.py:64:21: E999 SyntaxError: unmatched ')'
+def _run_sql(schema_editor, sqls):
+    def _run_sql(schema_editor, sqls):
+
+Original:
+    def _run_sql(self, schema_editor, sqls):
+*/
+
+-- 1. Move the `_run_sql` method from the `RunSQL` class to the top level of the file.
+UPDATE CLASS "RunSQL"
+FROM FILE "special.py"
+MOVE METHOD "_run_sql"
+INSERT BEFORE CLASS "RunSQL"
+RELATIVE INDENTATION 0;
+
+-- 2. Update the copied function to remove references to `self`.
+UPDATE FUNCTION "_run_sql"
+FROM FILE "special.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''def _run_sql''' THEN SUB
+r'''(def _run_sql\()self,\s*'''
+r'''\1schema_editor, sqls):
+'''
+END;
+
+-- 3. Update all call sites of the method `_run_sql` to call the new top-level function with the same name.
+UPDATE CLASS "RunSQL"
+FROM FILE "special.py"
+REPLACE BODY WITH CASE
+WHEN REGEX r'''self\._run_sql''' THEN SUB
+r'''self\.(_run_sql)'''
+r'''\1'''
+END;
+
+
+    ```
+</no-train>
diff --git a/tests/corpus/.refactoring-benchmark/config.py b/tests/corpus/.refactoring-benchmark/config.py
new file mode 100644
index 0000000..d6f278a
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/config.py
@@ -0,0 +1,475 @@
+# This code is part of Ansible, but is an independent component.
+# This particular file snippet, and this file snippet only, is BSD licensed.
+# Modules you write using this snippet, which is embedded dynamically by Ansible
+# still belong to the author of the module, and may assign their own license
+# to the complete work.
+#
+# (c) 2016 Red Hat Inc.
+#
+# Redistribution and use in source and binary forms, with or without modification,
+# are permitted provided that the following conditions are met:
+#
+#    * Redistributions of source code must retain the above copyright
+#      notice, this list of conditions and the following disclaimer.
+#    * Redistributions in binary form must reproduce the above copyright notice,
+#      this list of conditions and the following disclaimer in the documentation
+#      and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
+# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+from __future__ import annotations
+
+import re
+import hashlib
+
+from ansible.module_utils.six.moves import zip
+from ansible.module_utils.common.text.converters import to_bytes, to_native
+
+DEFAULT_COMMENT_TOKENS = ["#", "!", "/*", "*/", "echo"]
+
+DEFAULT_IGNORE_LINES_RE = set(
+    [
+        re.compile(r"Using \d+ out of \d+ bytes"),
+        re.compile(r"Building configuration"),
+        re.compile(r"Current configuration : \d+ bytes"),
+    ]
+)
+
+
+try:
+    Pattern = re._pattern_type
+except AttributeError:
+    Pattern = re.Pattern
+
+
+class ConfigLine(object):
+    def __init__(self, raw):
+        self.text = str(raw).strip()
+        self.raw = raw
+        self._children = list()
+        self._parents = list()
+
+    def __str__(self):
+        return self.raw
+
+    def __eq__(self, other):
+        return self.line == other.line
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __getitem__(self, key):
+        for item in self._children:
+            if item.text == key:
+                return item
+        raise KeyError(key)
+
+    @property
+    def line(self):
+        line = self.parents
+        line.append(self.text)
+        return " ".join(line)
+
+    @property
+    def children(self):
+        return _obj_to_text(self._children)
+
+    @property
+    def child_objs(self):
+        return self._children
+
+    @property
+    def parents(self):
+        return _obj_to_text(self._parents)
+
+    @property
+    def path(self):
+        config = _obj_to_raw(self._parents)
+        config.append(self.raw)
+        return "\n".join(config)
+
+    @property
+    def has_children(self):
+        return len(self._children) > 0
+
+    @property
+    def has_parents(self):
+        return len(self._parents) > 0
+
+    def add_child(self, obj):
+        if not isinstance(obj, ConfigLine):
+            raise AssertionError("child must be of type `ConfigLine`")
+        self._children.append(obj)
+
+
+def ignore_line(text, tokens=None):
+    for item in tokens or DEFAULT_COMMENT_TOKENS:
+        if text.startswith(item):
+            return True
+    for regex in DEFAULT_IGNORE_LINES_RE:
+        if regex.match(text):
+            return True
+
+
+def _obj_to_text(x):
+    return [o.text for o in x]
+
+
+def _obj_to_raw(x):
+    return [o.raw for o in x]
+
+
+def _obj_to_block(objects, visited=None):
+    items = list()
+    for o in objects:
+        if o not in items:
+            items.append(o)
+            for child in o._children:
+                if child not in items:
+                    items.append(child)
+    return _obj_to_raw(items)
+
+
+def dumps(objects, output="block", comments=False):
+    if output == "block":
+        items = _obj_to_block(objects)
+    elif output == "commands":
+        items = _obj_to_text(objects)
+    elif output == "raw":
+        items = _obj_to_raw(objects)
+    else:
+        raise TypeError("unknown value supplied for keyword output")
+
+    if output == "block":
+        if comments:
+            for index, item in enumerate(items):
+                nextitem = index + 1
+                if (
+                    nextitem < len(items)
+                    and not item.startswith(" ")
+                    and items[nextitem].startswith(" ")
+                ):
+                    item = "!\n%s" % item
+                items[index] = item
+            items.append("!")
+        items.append("end")
+
+    return "\n".join(items)
+
+
+class NetworkConfig(object):
+    def __init__(self, indent=1, contents=None, ignore_lines=None):
+        self._indent = indent
+        self._items = list()
+        self._config_text = None
+
+        if ignore_lines:
+            for item in ignore_lines:
+                if not isinstance(item, Pattern):
+                    item = re.compile(item)
+                DEFAULT_IGNORE_LINES_RE.add(item)
+
+        if contents:
+            self.load(contents)
+
+    @property
+    def items(self):
+        return self._items
+
+    @property
+    def config_text(self):
+        return self._config_text
+
+    @property
+    def sha1(self):
+        sha1 = hashlib.sha1()
+        sha1.update(to_bytes(str(self), errors="surrogate_or_strict"))
+        return sha1.digest()
+
+    def __getitem__(self, key):
+        for line in self:
+            if line.text == key:
+                return line
+        raise KeyError(key)
+
+    def __iter__(self):
+        return iter(self._items)
+
+    def __str__(self):
+        return "\n".join([c.raw for c in self.items])
+
+    def __len__(self):
+        return len(self._items)
+
+    def load(self, s):
+        self._config_text = s
+        self._items = self.parse(s)
+
+    def loadfp(self, fp):
+        with open(fp) as f:
+            return self.load(f.read())
+
+    def parse(self, lines, comment_tokens=None):
+        toplevel = re.compile(r"\S")
+        childline = re.compile(r"^\s*(.+)$")
+        entry_reg = re.compile(r"([{};])")
+
+        ancestors = list()
+        config = list()
+
+        indents = [0]
+
+        for linenum, line in enumerate(
+            to_native(lines, errors="surrogate_or_strict").split("\n")
+        ):
+            text = entry_reg.sub("", line).strip()
+
+            cfg = ConfigLine(line)
+
+            if not text or ignore_line(text, comment_tokens):
+                continue
+
+            # handle top level commands
+            if toplevel.match(line):
+                ancestors = [cfg]
+                indents = [0]
+
+            # handle sub level commands
+            else:
+                match = childline.match(line)
+                line_indent = match.start(1)
+
+                if line_indent < indents[-1]:
+                    while indents[-1] > line_indent:
+                        indents.pop()
+
+                if line_indent > indents[-1]:
+                    indents.append(line_indent)
+
+                curlevel = len(indents) - 1
+                parent_level = curlevel - 1
+
+                cfg._parents = ancestors[:curlevel]
+
+                if curlevel > len(ancestors):
+                    config.append(cfg)
+                    continue
+
+                for i in range(curlevel, len(ancestors)):
+                    ancestors.pop()
+
+                ancestors.append(cfg)
+                ancestors[parent_level].add_child(cfg)
+
+            config.append(cfg)
+
+        return config
+
+    def get_object(self, path):
+        for item in self.items:
+            if item.text == path[-1]:
+                if item.parents == path[:-1]:
+                    return item
+
+    def get_block(self, path):
+        if not isinstance(path, list):
+            raise AssertionError("path argument must be a list object")
+        obj = self.get_object(path)
+        if not obj:
+            raise ValueError("path does not exist in config")
+        return self._expand_block(obj)
+
+    def get_block_config(self, path):
+        block = self.get_block(path)
+        return dumps(block, "block")
+
+    def _expand_block(self, configobj, S=None):
+        if S is None:
+            S = list()
+        S.append(configobj)
+        for child in configobj._children:
+            if child in S:
+                continue
+            self._expand_block(child, S)
+        return S
+
+    def _diff_line(self, other):
+        updates = list()
+        for item in self.items:
+            if item not in other:
+                updates.append(item)
+        return updates
+
+    def _diff_strict(self, other):
+        updates = list()
+        # block extracted from other does not have all parents
+        # but the last one. In case of multiple parents we need
+        # to add additional parents.
+        if other and isinstance(other, list) and len(other) > 0:
+            start_other = other[0]
+            if start_other.parents:
+                for parent in start_other.parents:
+                    other.insert(0, ConfigLine(parent))
+        for index, line in enumerate(self.items):
+            try:
+                if str(line).strip() != str(other[index]).strip():
+                    updates.append(line)
+            except (AttributeError, IndexError):
+                updates.append(line)
+        return updates
+
+    def _diff_exact(self, other):
+        updates = list()
+        if len(other) != len(self.items):
+            updates.extend(self.items)
+        else:
+            for ours, theirs in zip(self.items, other):
+                if ours != theirs:
+                    updates.extend(self.items)
+                    break
+        return updates
+
+    def difference(self, other, match="line", path=None, replace=None):
+        """Perform a config diff against the another network config
+
+        :param other: instance of NetworkConfig to diff against
+        :param match: type of diff to perform.  valid values are 'line',
+            'strict', 'exact'
+        :param path: context in the network config to filter the diff
+        :param replace: the method used to generate the replacement lines.
+            valid values are 'block', 'line'
+
+        :returns: a string of lines that are different
+        """
+        if path and match != "line":
+            try:
+                other = other.get_block(path)
+            except ValueError:
+                other = list()
+        else:
+            other = other.items
+
+        # generate a list of ConfigLines that aren't in other
+        meth = getattr(self, "_diff_%s" % match)
+        updates = meth(other)
+
+        if replace == "block":
+            parents = list()
+            for item in updates:
+                if not item.has_parents:
+                    parents.append(item)
+                else:
+                    for p in item._parents:
+                        if p not in parents:
+                            parents.append(p)
+
+            updates = list()
+            for item in parents:
+                updates.extend(self._expand_block(item))
+
+        visited = set()
+        expanded = list()
+
+        for item in updates:
+            for p in item._parents:
+                if p.line not in visited:
+                    visited.add(p.line)
+                    expanded.append(p)
+            expanded.append(item)
+            visited.add(item.line)
+
+        return expanded
+
+    def add(self, lines, parents=None):
+        ancestors = list()
+        offset = 0
+        obj = None
+
+        # global config command
+        if not parents:
+            for line in lines:
+                # handle ignore lines
+                if ignore_line(line):
+                    continue
+
+                item = ConfigLine(line)
+                item.raw = line
+                if item not in self.items:
+                    self.items.append(item)
+
+        else:
+            for index, p in enumerate(parents):
+                try:
+                    i = index + 1
+                    obj = self.get_block(parents[:i])[0]
+                    ancestors.append(obj)
+
+                except ValueError:
+                    # add parent to config
+                    offset = index * self._indent
+                    obj = ConfigLine(p)
+                    obj.raw = p.rjust(len(p) + offset)
+                    if ancestors:
+                        obj._parents = list(ancestors)
+                        ancestors[-1]._children.append(obj)
+                    self.items.append(obj)
+                    ancestors.append(obj)
+
+            # add child objects
+            for line in lines:
+                # handle ignore lines
+                if ignore_line(line):
+                    continue
+
+                # check if child already exists
+                for child in ancestors[-1]._children:
+                    if child.text == line:
+                        break
+                else:
+                    offset = len(parents) * self._indent
+                    item = ConfigLine(line)
+                    item.raw = line.rjust(len(line) + offset)
+                    item._parents = ancestors
+                    ancestors[-1]._children.append(item)
+                    self.items.append(item)
+
+
+class CustomNetworkConfig(NetworkConfig):
+    def items_text(self):
+        return [item.text for item in self.items]
+
+    def expand_section(self, configobj, S=None):
+        if S is None:
+            S = list()
+        S.append(configobj)
+        for child in configobj.child_objs:
+            if child in S:
+                continue
+            self.expand_section(child, S)
+        return S
+
+    def to_block(self, section):
+        return "\n".join([item.raw for item in section])
+
+    def get_section(self, path):
+        try:
+            section = self.get_section_objects(path)
+            return self.to_block(section)
+        except ValueError:
+            return list()
+
+    def get_section_objects(self, path):
+        if not isinstance(path, list):
+            path = [path]
+        obj = self.get_object(path)
+        if not obj:
+            raise ValueError("path does not exist in config")
+        return self.expand_section(obj)
diff --git a/tests/corpus/.refactoring-benchmark/expected.config.py b/tests/corpus/.refactoring-benchmark/expected.config.py
new file mode 100644
index 0000000..d6f278a
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/expected.config.py
@@ -0,0 +1,475 @@
+# This code is part of Ansible, but is an independent component.
+# This particular file snippet, and this file snippet only, is BSD licensed.
+# Modules you write using this snippet, which is embedded dynamically by Ansible
+# still belong to the author of the module, and may assign their own license
+# to the complete work.
+#
+# (c) 2016 Red Hat Inc.
+#
+# Redistribution and use in source and binary forms, with or without modification,
+# are permitted provided that the following conditions are met:
+#
+#    * Redistributions of source code must retain the above copyright
+#      notice, this list of conditions and the following disclaimer.
+#    * Redistributions in binary form must reproduce the above copyright notice,
+#      this list of conditions and the following disclaimer in the documentation
+#      and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
+# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+from __future__ import annotations
+
+import re
+import hashlib
+
+from ansible.module_utils.six.moves import zip
+from ansible.module_utils.common.text.converters import to_bytes, to_native
+
+DEFAULT_COMMENT_TOKENS = ["#", "!", "/*", "*/", "echo"]
+
+DEFAULT_IGNORE_LINES_RE = set(
+    [
+        re.compile(r"Using \d+ out of \d+ bytes"),
+        re.compile(r"Building configuration"),
+        re.compile(r"Current configuration : \d+ bytes"),
+    ]
+)
+
+
+try:
+    Pattern = re._pattern_type
+except AttributeError:
+    Pattern = re.Pattern
+
+
+class ConfigLine(object):
+    def __init__(self, raw):
+        self.text = str(raw).strip()
+        self.raw = raw
+        self._children = list()
+        self._parents = list()
+
+    def __str__(self):
+        return self.raw
+
+    def __eq__(self, other):
+        return self.line == other.line
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __getitem__(self, key):
+        for item in self._children:
+            if item.text == key:
+                return item
+        raise KeyError(key)
+
+    @property
+    def line(self):
+        line = self.parents
+        line.append(self.text)
+        return " ".join(line)
+
+    @property
+    def children(self):
+        return _obj_to_text(self._children)
+
+    @property
+    def child_objs(self):
+        return self._children
+
+    @property
+    def parents(self):
+        return _obj_to_text(self._parents)
+
+    @property
+    def path(self):
+        config = _obj_to_raw(self._parents)
+        config.append(self.raw)
+        return "\n".join(config)
+
+    @property
+    def has_children(self):
+        return len(self._children) > 0
+
+    @property
+    def has_parents(self):
+        return len(self._parents) > 0
+
+    def add_child(self, obj):
+        if not isinstance(obj, ConfigLine):
+            raise AssertionError("child must be of type `ConfigLine`")
+        self._children.append(obj)
+
+
+def ignore_line(text, tokens=None):
+    for item in tokens or DEFAULT_COMMENT_TOKENS:
+        if text.startswith(item):
+            return True
+    for regex in DEFAULT_IGNORE_LINES_RE:
+        if regex.match(text):
+            return True
+
+
+def _obj_to_text(x):
+    return [o.text for o in x]
+
+
+def _obj_to_raw(x):
+    return [o.raw for o in x]
+
+
+def _obj_to_block(objects, visited=None):
+    items = list()
+    for o in objects:
+        if o not in items:
+            items.append(o)
+            for child in o._children:
+                if child not in items:
+                    items.append(child)
+    return _obj_to_raw(items)
+
+
+def dumps(objects, output="block", comments=False):
+    if output == "block":
+        items = _obj_to_block(objects)
+    elif output == "commands":
+        items = _obj_to_text(objects)
+    elif output == "raw":
+        items = _obj_to_raw(objects)
+    else:
+        raise TypeError("unknown value supplied for keyword output")
+
+    if output == "block":
+        if comments:
+            for index, item in enumerate(items):
+                nextitem = index + 1
+                if (
+                    nextitem < len(items)
+                    and not item.startswith(" ")
+                    and items[nextitem].startswith(" ")
+                ):
+                    item = "!\n%s" % item
+                items[index] = item
+            items.append("!")
+        items.append("end")
+
+    return "\n".join(items)
+
+
+class NetworkConfig(object):
+    def __init__(self, indent=1, contents=None, ignore_lines=None):
+        self._indent = indent
+        self._items = list()
+        self._config_text = None
+
+        if ignore_lines:
+            for item in ignore_lines:
+                if not isinstance(item, Pattern):
+                    item = re.compile(item)
+                DEFAULT_IGNORE_LINES_RE.add(item)
+
+        if contents:
+            self.load(contents)
+
+    @property
+    def items(self):
+        return self._items
+
+    @property
+    def config_text(self):
+        return self._config_text
+
+    @property
+    def sha1(self):
+        sha1 = hashlib.sha1()
+        sha1.update(to_bytes(str(self), errors="surrogate_or_strict"))
+        return sha1.digest()
+
+    def __getitem__(self, key):
+        for line in self:
+            if line.text == key:
+                return line
+        raise KeyError(key)
+
+    def __iter__(self):
+        return iter(self._items)
+
+    def __str__(self):
+        return "\n".join([c.raw for c in self.items])
+
+    def __len__(self):
+        return len(self._items)
+
+    def load(self, s):
+        self._config_text = s
+        self._items = self.parse(s)
+
+    def loadfp(self, fp):
+        with open(fp) as f:
+            return self.load(f.read())
+
+    def parse(self, lines, comment_tokens=None):
+        toplevel = re.compile(r"\S")
+        childline = re.compile(r"^\s*(.+)$")
+        entry_reg = re.compile(r"([{};])")
+
+        ancestors = list()
+        config = list()
+
+        indents = [0]
+
+        for linenum, line in enumerate(
+            to_native(lines, errors="surrogate_or_strict").split("\n")
+        ):
+            text = entry_reg.sub("", line).strip()
+
+            cfg = ConfigLine(line)
+
+            if not text or ignore_line(text, comment_tokens):
+                continue
+
+            # handle top level commands
+            if toplevel.match(line):
+                ancestors = [cfg]
+                indents = [0]
+
+            # handle sub level commands
+            else:
+                match = childline.match(line)
+                line_indent = match.start(1)
+
+                if line_indent < indents[-1]:
+                    while indents[-1] > line_indent:
+                        indents.pop()
+
+                if line_indent > indents[-1]:
+                    indents.append(line_indent)
+
+                curlevel = len(indents) - 1
+                parent_level = curlevel - 1
+
+                cfg._parents = ancestors[:curlevel]
+
+                if curlevel > len(ancestors):
+                    config.append(cfg)
+                    continue
+
+                for i in range(curlevel, len(ancestors)):
+                    ancestors.pop()
+
+                ancestors.append(cfg)
+                ancestors[parent_level].add_child(cfg)
+
+            config.append(cfg)
+
+        return config
+
+    def get_object(self, path):
+        for item in self.items:
+            if item.text == path[-1]:
+                if item.parents == path[:-1]:
+                    return item
+
+    def get_block(self, path):
+        if not isinstance(path, list):
+            raise AssertionError("path argument must be a list object")
+        obj = self.get_object(path)
+        if not obj:
+            raise ValueError("path does not exist in config")
+        return self._expand_block(obj)
+
+    def get_block_config(self, path):
+        block = self.get_block(path)
+        return dumps(block, "block")
+
+    def _expand_block(self, configobj, S=None):
+        if S is None:
+            S = list()
+        S.append(configobj)
+        for child in configobj._children:
+            if child in S:
+                continue
+            self._expand_block(child, S)
+        return S
+
+    def _diff_line(self, other):
+        updates = list()
+        for item in self.items:
+            if item not in other:
+                updates.append(item)
+        return updates
+
+    def _diff_strict(self, other):
+        updates = list()
+        # block extracted from other does not have all parents
+        # but the last one. In case of multiple parents we need
+        # to add additional parents.
+        if other and isinstance(other, list) and len(other) > 0:
+            start_other = other[0]
+            if start_other.parents:
+                for parent in start_other.parents:
+                    other.insert(0, ConfigLine(parent))
+        for index, line in enumerate(self.items):
+            try:
+                if str(line).strip() != str(other[index]).strip():
+                    updates.append(line)
+            except (AttributeError, IndexError):
+                updates.append(line)
+        return updates
+
+    def _diff_exact(self, other):
+        updates = list()
+        if len(other) != len(self.items):
+            updates.extend(self.items)
+        else:
+            for ours, theirs in zip(self.items, other):
+                if ours != theirs:
+                    updates.extend(self.items)
+                    break
+        return updates
+
+    def difference(self, other, match="line", path=None, replace=None):
+        """Perform a config diff against the another network config
+
+        :param other: instance of NetworkConfig to diff against
+        :param match: type of diff to perform.  valid values are 'line',
+            'strict', 'exact'
+        :param path: context in the network config to filter the diff
+        :param replace: the method used to generate the replacement lines.
+            valid values are 'block', 'line'
+
+        :returns: a string of lines that are different
+        """
+        if path and match != "line":
+            try:
+                other = other.get_block(path)
+            except ValueError:
+                other = list()
+        else:
+            other = other.items
+
+        # generate a list of ConfigLines that aren't in other
+        meth = getattr(self, "_diff_%s" % match)
+        updates = meth(other)
+
+        if replace == "block":
+            parents = list()
+            for item in updates:
+                if not item.has_parents:
+                    parents.append(item)
+                else:
+                    for p in item._parents:
+                        if p not in parents:
+                            parents.append(p)
+
+            updates = list()
+            for item in parents:
+                updates.extend(self._expand_block(item))
+
+        visited = set()
+        expanded = list()
+
+        for item in updates:
+            for p in item._parents:
+                if p.line not in visited:
+                    visited.add(p.line)
+                    expanded.append(p)
+            expanded.append(item)
+            visited.add(item.line)
+
+        return expanded
+
+    def add(self, lines, parents=None):
+        ancestors = list()
+        offset = 0
+        obj = None
+
+        # global config command
+        if not parents:
+            for line in lines:
+                # handle ignore lines
+                if ignore_line(line):
+                    continue
+
+                item = ConfigLine(line)
+                item.raw = line
+                if item not in self.items:
+                    self.items.append(item)
+
+        else:
+            for index, p in enumerate(parents):
+                try:
+                    i = index + 1
+                    obj = self.get_block(parents[:i])[0]
+                    ancestors.append(obj)
+
+                except ValueError:
+                    # add parent to config
+                    offset = index * self._indent
+                    obj = ConfigLine(p)
+                    obj.raw = p.rjust(len(p) + offset)
+                    if ancestors:
+                        obj._parents = list(ancestors)
+                        ancestors[-1]._children.append(obj)
+                    self.items.append(obj)
+                    ancestors.append(obj)
+
+            # add child objects
+            for line in lines:
+                # handle ignore lines
+                if ignore_line(line):
+                    continue
+
+                # check if child already exists
+                for child in ancestors[-1]._children:
+                    if child.text == line:
+                        break
+                else:
+                    offset = len(parents) * self._indent
+                    item = ConfigLine(line)
+                    item.raw = line.rjust(len(line) + offset)
+                    item._parents = ancestors
+                    ancestors[-1]._children.append(item)
+                    self.items.append(item)
+
+
+class CustomNetworkConfig(NetworkConfig):
+    def items_text(self):
+        return [item.text for item in self.items]
+
+    def expand_section(self, configobj, S=None):
+        if S is None:
+            S = list()
+        S.append(configobj)
+        for child in configobj.child_objs:
+            if child in S:
+                continue
+            self.expand_section(child, S)
+        return S
+
+    def to_block(self, section):
+        return "\n".join([item.raw for item in section])
+
+    def get_section(self, path):
+        try:
+            section = self.get_section_objects(path)
+            return self.to_block(section)
+        except ValueError:
+            return list()
+
+    def get_section_objects(self, path):
+        if not isinstance(path, list):
+            path = [path]
+        obj = self.get_object(path)
+        if not obj:
+            raise ValueError("path does not exist in config")
+        return self.expand_section(obj)
diff --git a/tests/corpus/.refactoring-benchmark/expected.generic_bsd.py b/tests/corpus/.refactoring-benchmark/expected.generic_bsd.py
new file mode 100644
index 0000000..fc0f5d6
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/expected.generic_bsd.py
@@ -0,0 +1,320 @@
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
+
+from __future__ import annotations
+
+import re
+import socket
+import struct
+
+from ansible.module_utils.facts.network.base import Network
+
+
+def parse_inet_line( words, current_if, ips):
+    # netbsd show aliases like this
+    #  lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 33184
+    #         inet 127.0.0.1 netmask 0xff000000
+    #         inet alias 127.1.1.1 netmask 0xff000000
+    if words[1] == 'alias':
+        del words[1]
+
+    address = {'address': words[1]}
+    # cidr style ip address (eg, 127.0.0.1/24) in inet line
+    # used in netbsd ifconfig -e output after 7.1
+    if '/' in address['address']:
+        ip_address, cidr_mask = address['address'].split('/')
+
+        address['address'] = ip_address
+
+        netmask_length = int(cidr_mask)
+        netmask_bin = (1 << 32) - (1 << 32 >> int(netmask_length))
+        address['netmask'] = socket.inet_ntoa(struct.pack('!L', netmask_bin))
+
+        if len(words) > 5:
+            address['broadcast'] = words[3]
+
+    else:
+        # Don't just assume columns, use "netmask" as the index for the prior column
+        try:
+            netmask_idx = words.index('netmask') + 1
+        except ValueError:
+            netmask_idx = 3
+
+        # deal with hex netmask
+        if re.match('([0-9a-f]){8}$', words[netmask_idx]):
+            netmask = '0x' + words[netmask_idx]
+        else:
+            netmask = words[netmask_idx]
+
+        if netmask.startswith('0x'):
+            address['netmask'] = socket.inet_ntoa(struct.pack('!L', int(netmask, base=16)))
+        else:
+            # otherwise assume this is a dotted quad
+            address['netmask'] = netmask
+    # calculate the network
+    address_bin = struct.unpack('!L', socket.inet_aton(address['address']))[0]
+    netmask_bin = struct.unpack('!L', socket.inet_aton(address['netmask']))[0]
+    address['network'] = socket.inet_ntoa(struct.pack('!L', address_bin & netmask_bin))
+    if 'broadcast' not in address:
+        # broadcast may be given or we need to calculate
+        try:
+            broadcast_idx = words.index('broadcast') + 1
+        except ValueError:
+            address['broadcast'] = socket.inet_ntoa(struct.pack('!L', address_bin | (~netmask_bin & 0xffffffff)))
+        else:
+            address['broadcast'] = words[broadcast_idx]
+
+    # add to our list of addresses
+    if not words[1].startswith('127.'):
+        ips['all_ipv4_addresses'].append(address['address'])
+    current_if['ipv4'].append(address)
+class GenericBsdIfconfigNetwork(Network):
+    """
+    This is a generic BSD subclass of Network using the ifconfig command.
+    It defines
+    - interfaces (a list of interface names)
+    - interface_<name> dictionary of ipv4, ipv6, and mac address information.
+    - all_ipv4_addresses and all_ipv6_addresses: lists of all configured addresses.
+    """
+    platform = 'Generic_BSD_Ifconfig'
+
+    def populate(self, collected_facts=None):
+        network_facts = {}
+        ifconfig_path = self.module.get_bin_path('ifconfig')
+
+        if ifconfig_path is None:
+            return network_facts
+
+        route_path = self.module.get_bin_path('route')
+
+        if route_path is None:
+            return network_facts
+
+        default_ipv4, default_ipv6 = self.get_default_interfaces(route_path)
+        interfaces, ips = self.get_interfaces_info(ifconfig_path)
+        interfaces = self.detect_type_media(interfaces)
+
+        self.merge_default_interface(default_ipv4, interfaces, 'ipv4')
+        self.merge_default_interface(default_ipv6, interfaces, 'ipv6')
+        network_facts['interfaces'] = sorted(list(interfaces.keys()))
+
+        for iface in interfaces:
+            network_facts[iface] = interfaces[iface]
+
+        network_facts['default_ipv4'] = default_ipv4
+        network_facts['default_ipv6'] = default_ipv6
+        network_facts['all_ipv4_addresses'] = ips['all_ipv4_addresses']
+        network_facts['all_ipv6_addresses'] = ips['all_ipv6_addresses']
+
+        return network_facts
+
+    def detect_type_media(self, interfaces):
+        for iface in interfaces:
+            if 'media' in interfaces[iface]:
+                if 'ether' in interfaces[iface]['media'].lower():
+                    interfaces[iface]['type'] = 'ether'
+        return interfaces
+
+    def get_default_interfaces(self, route_path):
+
+        # Use the commands:
+        #     route -n get default
+        #     route -n get -inet6 default
+        # to find out the default outgoing interface, address, and gateway
+
+        command = dict(v4=[route_path, '-n', 'get', 'default'],
+                       v6=[route_path, '-n', 'get', '-inet6', 'default'])
+
+        interface = dict(v4={}, v6={})
+
+        for v in 'v4', 'v6':
+
+            if v == 'v6' and not socket.has_ipv6:
+                continue
+            rc, out, err = self.module.run_command(command[v])
+            if not out:
+                # v6 routing may result in
+                #   RTNETLINK answers: Invalid argument
+                continue
+            for line in out.splitlines():
+                words = line.strip().split(': ')
+                # Collect output from route command
+                if len(words) > 1:
+                    if words[0] == 'interface':
+                        interface[v]['interface'] = words[1]
+                    if words[0] == 'gateway':
+                        interface[v]['gateway'] = words[1]
+                    # help pick the right interface address on OpenBSD
+                    if words[0] == 'if address':
+                        interface[v]['address'] = words[1]
+                    # help pick the right interface address on NetBSD
+                    if words[0] == 'local addr':
+                        interface[v]['address'] = words[1]
+
+        return interface['v4'], interface['v6']
+
+    def get_interfaces_info(self, ifconfig_path, ifconfig_options='-a'):
+        interfaces = {}
+        current_if = {}
+        ips = dict(
+            all_ipv4_addresses=[],
+            all_ipv6_addresses=[],
+        )
+        # FreeBSD, DragonflyBSD, NetBSD, OpenBSD and macOS all implicitly add '-a'
+        # when running the command 'ifconfig'.
+        # Solaris must explicitly run the command 'ifconfig -a'.
+        rc, out, err = self.module.run_command([ifconfig_path, ifconfig_options])
+
+        for line in out.splitlines():
+
+            if line:
+                words = line.split()
+
+                if words[0] == 'pass':
+                    continue
+                elif re.match(r'^\S', line) and len(words) > 3:
+                    current_if = self.parse_interface_line(words)
+                    interfaces[current_if['device']] = current_if
+                elif words[0].startswith('options='):
+                    self.parse_options_line(words, current_if, ips)
+                elif words[0] == 'nd6':
+                    self.parse_nd6_line(words, current_if, ips)
+                elif words[0] == 'ether':
+                    self.parse_ether_line(words, current_if, ips)
+                elif words[0] == 'media:':
+                    self.parse_media_line(words, current_if, ips)
+                elif words[0] == 'status:':
+                    self.parse_status_line(words, current_if, ips)
+                elif words[0] == 'lladdr':
+                    self.parse_lladdr_line(words, current_if, ips)
+                elif words[0] == 'inet':
+                    parse_inet_line(words, current_if, ips)
+                elif words[0] == 'inet6':
+                    self.parse_inet6_line(words, current_if, ips)
+                elif words[0] == 'tunnel':
+                    self.parse_tunnel_line(words, current_if, ips)
+                else:
+                    self.parse_unknown_line(words, current_if, ips)
+
+        return interfaces, ips
+
+    def parse_interface_line(self, words):
+        device = words[0][0:-1]
+        current_if = {'device': device, 'ipv4': [], 'ipv6': [], 'type': 'unknown'}
+        current_if['flags'] = self.get_options(words[1])
+        if 'LOOPBACK' in current_if['flags']:
+            current_if['type'] = 'loopback'
+        current_if['macaddress'] = 'unknown'    # will be overwritten later
+
+        if len(words) >= 5:  # Newer FreeBSD versions
+            current_if['metric'] = words[3]
+            current_if['mtu'] = words[5]
+        else:
+            current_if['mtu'] = words[3]
+
+        return current_if
+
+    def parse_options_line(self, words, current_if, ips):
+        # Mac has options like this...
+        current_if['options'] = self.get_options(words[0])
+
+    def parse_nd6_line(self, words, current_if, ips):
+        # FreeBSD has options like this...
+        current_if['options'] = self.get_options(words[1])
+
+    def parse_ether_line(self, words, current_if, ips):
+        current_if['macaddress'] = words[1]
+        current_if['type'] = 'ether'
+
+    def parse_media_line(self, words, current_if, ips):
+        # not sure if this is useful - we also drop information
+        current_if['media'] = words[1]
+        if len(words) > 2:
+            current_if['media_select'] = words[2]
+        if len(words) > 3:
+            current_if['media_type'] = words[3][1:]
+        if len(words) > 4:
+            current_if['media_options'] = self.get_options(words[4])
+
+    def parse_status_line(self, words, current_if, ips):
+        current_if['status'] = words[1]
+
+    def parse_lladdr_line(self, words, current_if, ips):
+        current_if['lladdr'] = words[1]
+
+
+    def parse_inet6_line(self, words, current_if, ips):
+        address = {'address': words[1]}
+
+        # using cidr style addresses, ala NetBSD ifconfig post 7.1
+        if '/' in address['address']:
+            ip_address, cidr_mask = address['address'].split('/')
+
+            address['address'] = ip_address
+            address['prefix'] = cidr_mask
+
+            if len(words) > 5:
+                address['scope'] = words[5]
+        else:
+            if (len(words) >= 4) and (words[2] == 'prefixlen'):
+                address['prefix'] = words[3]
+            if (len(words) >= 6) and (words[4] == 'scopeid'):
+                address['scope'] = words[5]
+
+        localhost6 = ['::1', '::1/128', 'fe80::1%lo0']
+        if address['address'] not in localhost6:
+            ips['all_ipv6_addresses'].append(address['address'])
+        current_if['ipv6'].append(address)
+
+    def parse_tunnel_line(self, words, current_if, ips):
+        current_if['type'] = 'tunnel'
+
+    def parse_unknown_line(self, words, current_if, ips):
+        # we are going to ignore unknown lines here - this may be
+        # a bad idea - but you can override it in your subclass
+        pass
+
+    # TODO: these are module scope static function candidates
+    #       (most of the class is really...)
+    def get_options(self, option_string):
+        start = option_string.find('<') + 1
+        end = option_string.rfind('>')
+        if (start > 0) and (end > 0) and (end > start + 1):
+            option_csv = option_string[start:end]
+            return option_csv.split(',')
+        else:
+            return []
+
+    def merge_default_interface(self, defaults, interfaces, ip_type):
+        if 'interface' not in defaults:
+            return
+        if not defaults['interface'] in interfaces:
+            return
+        ifinfo = interfaces[defaults['interface']]
+        # copy all the interface values across except addresses
+        for item in ifinfo:
+            if item != 'ipv4' and item != 'ipv6':
+                defaults[item] = ifinfo[item]
+
+        ipinfo = []
+        if 'address' in defaults:
+            ipinfo = [x for x in ifinfo[ip_type] if x['address'] == defaults['address']]
+
+        if len(ipinfo) == 0:
+            ipinfo = ifinfo[ip_type]
+
+        if len(ipinfo) > 0:
+            for item in ipinfo[0]:
+                defaults[item] = ipinfo[0][item]
diff --git a/tests/corpus/.refactoring-benchmark/expected.graph_drawer.py b/tests/corpus/.refactoring-benchmark/expected.graph_drawer.py
new file mode 100644
index 0000000..2e41811
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/expected.graph_drawer.py
@@ -0,0 +1,418 @@
+
+import hashlib
+import torch
+import torch.fx
+from typing import Any, Dict, Optional, TYPE_CHECKING
+from torch.fx.node import _get_qualified_name, _format_arg
+from torch.fx.graph import _parse_stack_trace
+from torch.fx.passes.shape_prop import TensorMetadata
+from torch.fx._compatibility import compatibility
+from itertools import chain
+
+__all__ = ['FxGraphDrawer']
+try:
+    import pydot
+    HAS_PYDOT = True
+except ImportError:
+    HAS_PYDOT = False
+
+_COLOR_MAP = {
+    "placeholder": '"AliceBlue"',
+    "call_module": "LemonChiffon1",
+    "get_param": "Yellow2",
+    "get_attr": "LightGrey",
+    "output": "PowderBlue",
+}
+
+_HASH_COLOR_MAP = [
+    "CadetBlue1",
+    "Coral",
+    "DarkOliveGreen1",
+    "DarkSeaGreen1",
+    "GhostWhite",
+    "Khaki1",
+    "LavenderBlush1",
+    "LightSkyBlue",
+    "MistyRose1",
+    "MistyRose2",
+    "PaleTurquoise2",
+    "PeachPuff1",
+    "Salmon",
+    "Thistle1",
+    "Thistle3",
+    "Wheat1",
+]
+
+_WEIGHT_TEMPLATE = {
+    "fillcolor": "Salmon",
+    "style": '"filled,rounded"',
+    "fontcolor": "#000000",
+}
+
+if HAS_PYDOT:
+    @compatibility(is_backward_compatible=False)
+    class FxGraphDrawer:
+        """
+        Visualize a torch.fx.Graph with graphviz
+        Basic usage:
+            g = FxGraphDrawer(symbolic_traced, "resnet18")
+            g.get_dot_graph().write_svg("a.svg")
+        """
+
+        def __init__(
+            self,
+            graph_module: torch.fx.GraphModule,
+            name: str,
+            ignore_getattr: bool = False,
+            ignore_parameters_and_buffers: bool = False,
+            skip_node_names_in_args: bool = True,
+            parse_stack_trace: bool = False,
+            dot_graph_shape: Optional[str] = None,
+        ):
+            self._name = name
+            self.dot_graph_shape = (
+                dot_graph_shape if dot_graph_shape is not None else "record"
+            )
+            _WEIGHT_TEMPLATE["shape"] = self.dot_graph_shape
+
+            self._dot_graphs = {
+                name: self._to_dot(
+                    graph_module, name, ignore_getattr, ignore_parameters_and_buffers, skip_node_names_in_args, parse_stack_trace
+                )
+            }
+
+            for node in graph_module.graph.nodes:
+                if node.op != "call_module":
+                    continue
+
+                leaf_node = self._get_leaf_node(graph_module, node)
+
+                if not isinstance(leaf_node, torch.fx.GraphModule):
+                    continue
+
+
+                self._dot_graphs[f"{name}_{node.target}"] = self._to_dot(
+                    leaf_node,
+                    f"{name}_{node.target}",
+                    ignore_getattr,
+                    ignore_parameters_and_buffers,
+                    skip_node_names_in_args,
+                    parse_stack_trace,
+                )
+
+        def get_dot_graph(self, submod_name=None) -> pydot.Dot:
+            """
+            Visualize a torch.fx.Graph with graphviz
+            Example:
+                >>> # xdoctest: +REQUIRES(module:pydot)
+                >>> # define module
+                >>> class MyModule(torch.nn.Module):
+                >>>     def __init__(self):
+                >>>         super().__init__()
+                >>>         self.linear = torch.nn.Linear(4, 5)
+                >>>     def forward(self, x):
+                >>>         return self.linear(x).clamp(min=0.0, max=1.0)
+                >>> module = MyModule()
+                >>> # trace the module
+                >>> symbolic_traced = torch.fx.symbolic_trace(module)
+                >>> # setup output file
+                >>> import ubelt as ub
+                >>> dpath = ub.Path.appdir('torch/tests/FxGraphDrawer').ensuredir()
+                >>> fpath = dpath / 'linear.svg'
+                >>> # draw the graph
+                >>> g = FxGraphDrawer(symbolic_traced, "linear")
+                >>> g.get_dot_graph().write_svg(fpath)
+            """
+            if submod_name is None:
+                return self.get_main_dot_graph()
+            else:
+                return self.get_submod_dot_graph(submod_name)
+
+        def get_main_dot_graph(self) -> pydot.Dot:
+            return self._dot_graphs[self._name]
+
+        def get_submod_dot_graph(self, submod_name) -> pydot.Dot:
+            return self._dot_graphs[f"{self._name}_{submod_name}"]
+
+        def get_all_dot_graphs(self) -> Dict[str, pydot.Dot]:
+            return self._dot_graphs
+
+        def _get_node_style(self, node: torch.fx.Node) -> Dict[str, str]:
+
+            template = {
+                "shape": self.dot_graph_shape,
+                "fillcolor": "#CAFFE3",
+                "style": '"filled,rounded"',
+                "fontcolor": "#000000",
+            }
+            if node.op in _COLOR_MAP:
+                template["fillcolor"] = _COLOR_MAP[node.op]
+            else:
+                # Use a random color for each node; based on its name so it's stable.
+                target_name = node._pretty_print_target(node.target)
+                target_hash = int(hashlib.md5(target_name.encode()).hexdigest()[:8], 16)
+                template["fillcolor"] = _HASH_COLOR_MAP[target_hash % len(_HASH_COLOR_MAP)]
+            return template
+
+        def _get_leaf_node(
+            self, module: torch.nn.Module, node: torch.fx.Node
+        ) -> torch.nn.Module:
+            py_obj = module
+            assert isinstance(node.target, str)
+            atoms = node.target.split(".")
+            for atom in atoms:
+                if not hasattr(py_obj, atom):
+                    raise RuntimeError(
+                        str(py_obj) + " does not have attribute " + atom + "!"
+                    )
+                py_obj = getattr(py_obj, atom)
+            return py_obj
+
+        def _typename(self, target: Any) -> str:
+            if isinstance(target, torch.nn.Module):
+                ret = torch.typename(target)
+            elif isinstance(target, str):
+                ret = target
+            else:
+                ret = _get_qualified_name(target)
+
+            # Escape "{" and "}" to prevent dot files like:
+            # https://gist.github.com/SungMinCho/1a017aab662c75d805c5954d62c5aabc
+            # which triggers `Error: bad label format (...)` from dot
+            return ret.replace("{", r"\{").replace("}", r"\}")
+
+        # shorten path to avoid drawing long boxes
+        # for full path = '/home/weif/pytorch/test.py'
+        # return short path = 'pytorch/test.py'
+        def _shorten_file_name(
+            self,
+            full_file_name: str,
+            truncate_to_last_n: int = 2,
+        ):
+            splits = full_file_name.split('/')
+            if len(splits) >= truncate_to_last_n:
+                return '/'.join(splits[-truncate_to_last_n:])
+            return full_file_name
+
+
+        def _get_node_label(
+            self,
+            module: torch.fx.GraphModule,
+            node: torch.fx.Node,
+            skip_node_names_in_args: bool,
+            parse_stack_trace: bool,
+        ) -> str:
+            def _get_str_for_args_kwargs(arg):
+                if isinstance(arg, tuple):
+                    prefix, suffix = r"|args=(\l", r",\n)\l"
+                    arg_strs_list = [_format_arg(a, max_list_len=8) for a in arg]
+                elif isinstance(arg, dict):
+                    prefix, suffix = r"|kwargs={\l", r",\n}\l"
+                    arg_strs_list = [
+                        f"{k}: {_format_arg(v, max_list_len=8)}"
+                        for k, v in arg.items()
+                    ]
+                else:  # Fall back to nothing in unexpected case.
+                    return ""
+
+                # Strip out node names if requested.
+                if skip_node_names_in_args:
+                    arg_strs_list = [a for a in arg_strs_list if "%" not in a]
+                if len(arg_strs_list) == 0:
+                    return ""
+                arg_strs = prefix + r",\n".join(arg_strs_list) + suffix
+                if len(arg_strs_list) == 1:
+                    arg_strs = arg_strs.replace(r"\l", "").replace(r"\n", "")
+                return arg_strs.replace("{", r"\{").replace("}", r"\}")
+
+
+            label = "{" + f"name=%{node.name}|op_code={node.op}\n"
+
+            if node.op == "call_module":
+                leaf_module = self._get_leaf_node(module, node)
+                label += r"\n" + self._typename(leaf_module) + r"\n|"
+                extra = ""
+                if hasattr(leaf_module, "__constants__"):
+                    extra = r"\n".join(
+                        [f"{c}: {getattr(leaf_module, c)}" for c in leaf_module.__constants__]  # type: ignore[union-attr]
+                    )
+                label += extra + r"\n"
+            else:
+                label += f"|target={self._typename(node.target)}" + r"\n"
+                if len(node.args) > 0:
+                    label += _get_str_for_args_kwargs(node.args)
+                if len(node.kwargs) > 0:
+                    label += _get_str_for_args_kwargs(node.kwargs)
+                label += f"|num_users={len(node.users)}" + r"\n"
+
+            tensor_meta = node.meta.get('tensor_meta')
+            label += self._tensor_meta_to_label(tensor_meta)
+
+            # for original fx graph
+            # print buf=buf0, n_origin=6
+            buf_meta = node.meta.get('buf_meta', None)
+            if buf_meta is not None:
+                label += f"|buf={buf_meta.name}" + r"\n"
+                label += f"|n_origin={buf_meta.n_origin}" + r"\n"
+
+            # for original fx graph
+            # print file:lineno code
+            if parse_stack_trace and node.stack_trace is not None:
+                parsed_stack_trace = _parse_stack_trace(node.stack_trace)
+                fname = self._shorten_file_name(parsed_stack_trace.file)
+                label += f"|file={fname}:{parsed_stack_trace.lineno} {parsed_stack_trace.code}" + r"\n"
+
+
+            return label + "}"
+
+        def _tensor_meta_to_label(self, tm) -> str:
+            if tm is None:
+                return ""
+            elif isinstance(tm, TensorMetadata):
+                return self._stringify_tensor_meta(tm)
+            elif isinstance(tm, list):
+                result = ""
+                for item in tm:
+                    result += self._tensor_meta_to_label(item)
+                return result
+            elif isinstance(tm, dict):
+                result = ""
+                for v in tm.values():
+                    result += self._tensor_meta_to_label(v)
+                return result
+            elif isinstance(tm, tuple):
+                result = ""
+                for item in tm:
+                    result += self._tensor_meta_to_label(item)
+                return result
+            else:
+                raise RuntimeError(f"Unsupported tensor meta type {type(tm)}")
+
+        def _stringify_tensor_meta(self, tm: TensorMetadata) -> str:
+            result = ""
+            if not hasattr(tm, "dtype"):
+                print("tm", tm)
+            result += "|" + "dtype" + "=" + str(tm.dtype) + r"\n"
+            result += "|" + "shape" + "=" + str(tuple(tm.shape)) + r"\n"
+            result += "|" + "requires_grad" + "=" + str(tm.requires_grad) + r"\n"
+            result += "|" + "stride" + "=" + str(tm.stride) + r"\n"
+            if tm.is_quantized:
+                assert tm.qparams is not None
+                assert "qscheme" in tm.qparams
+                qscheme = tm.qparams["qscheme"]
+                if qscheme in {
+                        torch.per_tensor_affine,
+                        torch.per_tensor_symmetric,
+                }:
+                    result += "|" + "q_scale" + "=" + str(tm.qparams["scale"]) + r"\n"
+                    result += "|" + "q_zero_point" + "=" + str(tm.qparams["zero_point"]) + r"\n"
+                elif qscheme in {
+                        torch.per_channel_affine,
+                        torch.per_channel_symmetric,
+                        torch.per_channel_affine_float_qparams,
+                }:
+                    result += "|" + "q_per_channel_scale" + "=" + str(tm.qparams["scale"]) + r"\n"
+                    result += "|" + "q_per_channel_zero_point" + "=" + str(tm.qparams["zero_point"]) + r"\n"
+                    result += "|" + "q_per_channel_axis" + "=" + str(tm.qparams["axis"]) + r"\n"
+                else:
+                    raise RuntimeError(f"Unsupported qscheme: {qscheme}")
+                result += "|" + "qscheme" + "=" + str(tm.qparams["qscheme"]) + r"\n"
+            return result
+
+        def _get_tensor_label(self, t: torch.Tensor) -> str:
+            return str(t.dtype) + str(list(t.shape)) + r"\n"
+
+        # when parse_stack_trace=True
+        # print file:lineno code
+        def _to_dot(
+            self,
+            graph_module: torch.fx.GraphModule,
+            name: str,
+            ignore_getattr: bool,
+            ignore_parameters_and_buffers: bool,
+            skip_node_names_in_args: bool,
+            parse_stack_trace: bool,
+        ) -> pydot.Dot:
+            """
+            Actual interface to visualize a fx.Graph. Note that it takes in the GraphModule instead of the Graph.
+            If ignore_parameters_and_buffers is True, the parameters and buffers
+            created with the module will not be added as nodes and edges.
+            """
+
+            # "TB" means top-to-bottom rank direction in layout
+            dot_graph = pydot.Dot(name, rankdir="TB")
+
+
+            buf_name_to_subgraph = {}
+
+            for node in graph_module.graph.nodes:
+                if ignore_getattr and node.op == "get_attr":
+                    continue
+
+                style = self._get_node_style(node)
+                dot_node = pydot.Node(
+                    node.name, label=self._get_node_label(graph_module, node, skip_node_names_in_args, parse_stack_trace), **style
+                )
+
+                current_graph = dot_graph
+
+                buf_meta = node.meta.get('buf_meta', None)
+                if buf_meta is not None and buf_meta.n_origin > 1:
+                    buf_name = buf_meta.name
+                    if buf_name not in buf_name_to_subgraph:
+                        buf_name_to_subgraph[buf_name] = pydot.Cluster(buf_name, label=buf_name)
+                    current_graph = buf_name_to_subgraph.get(buf_name)
+
+                current_graph.add_node(dot_node)
+
+                def get_module_params_or_buffers():
+                    for pname, ptensor in chain(
+                        leaf_module.named_parameters(), leaf_module.named_buffers()
+                    ):
+                        pname1 = node.name + "." + pname
+                        label1 = (
+                            pname1 + "|op_code=get_" + "parameter"
+                            if isinstance(ptensor, torch.nn.Parameter)
+                            else "buffer" + r"\l"
+                        )
+                        dot_w_node = pydot.Node(
+                            pname1,
+                            label="{" + label1 + self._get_tensor_label(ptensor) + "}",
+                            **_WEIGHT_TEMPLATE,
+                        )
+                        dot_graph.add_node(dot_w_node)
+                        dot_graph.add_edge(pydot.Edge(pname1, node.name))
+
+                if node.op == "call_module":
+                    leaf_module = self._get_leaf_node(graph_module, node)
+
+                    if not ignore_parameters_and_buffers and not isinstance(leaf_module, torch.fx.GraphModule):
+                        get_module_params_or_buffers()
+
+            for subgraph in buf_name_to_subgraph.values():
+                subgraph.set('color', 'royalblue')
+                subgraph.set('penwidth', '2')
+                dot_graph.add_subgraph(subgraph)
+
+            for node in graph_module.graph.nodes:
+                if ignore_getattr and node.op == "get_attr":
+                    continue
+
+                for user in node.users:
+                    dot_graph.add_edge(pydot.Edge(node.name, user.name))
+
+            return dot_graph
+
+else:
+    if not TYPE_CHECKING:
+        @compatibility(is_backward_compatible=False)
+        class FxGraphDrawer:
+            def __init__(
+                self,
+                graph_module: torch.fx.GraphModule,
+                name: str,
+                ignore_getattr: bool = False,
+                parse_stack_trace: bool = False,
+            ):
+                raise RuntimeError('FXGraphDrawer requires the pydot package to be installed. Please install '
+                                   'pydot through your favorite Python package manager.')
diff --git a/tests/corpus/.refactoring-benchmark/expected.makemessages.py b/tests/corpus/.refactoring-benchmark/expected.makemessages.py
new file mode 100644
index 0000000..096d567
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/expected.makemessages.py
@@ -0,0 +1,783 @@
+import glob
+import os
+import re
+import sys
+from functools import total_ordering
+from itertools import dropwhile
+from pathlib import Path
+
+import django
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from django.core.files.temp import NamedTemporaryFile
+from django.core.management.base import BaseCommand, CommandError
+from django.core.management.utils import (
+    find_command,
+    handle_extensions,
+    is_ignored_path,
+    popen_wrapper,
+)
+from django.utils.encoding import DEFAULT_LOCALE_ENCODING
+from django.utils.functional import cached_property
+from django.utils.jslex import prepare_js_for_gettext
+from django.utils.regex_helper import _lazy_re_compile
+from django.utils.text import get_text_list
+from django.utils.translation import templatize
+
+plural_forms_re = _lazy_re_compile(
+    r'^(?P<value>"Plural-Forms.+?\\n")\s*$', re.MULTILINE | re.DOTALL
+)
+STATUS_OK = 0
+NO_LOCALE_DIR = object()
+
+
+def check_programs(*programs):
+    for program in programs:
+        if find_command(program) is None:
+            raise CommandError(
+                "Can't find %s. Make sure you have GNU gettext tools 0.15 or "
+                "newer installed." % program
+            )
+
+
+def is_valid_locale(locale):
+    return re.match(r"^[a-z]+$", locale) or re.match(r"^[a-z]+_[A-Z].*$", locale)
+
+
+@total_ordering
+class TranslatableFile:
+    def __init__(self, dirpath, file_name, locale_dir):
+        self.file = file_name
+        self.dirpath = dirpath
+        self.locale_dir = locale_dir
+
+    def __repr__(self):
+        return "<%s: %s>" % (
+            self.__class__.__name__,
+            os.sep.join([self.dirpath, self.file]),
+        )
+
+    def __eq__(self, other):
+        return self.path == other.path
+
+    def __lt__(self, other):
+        return self.path < other.path
+
+    @property
+    def path(self):
+        return os.path.join(self.dirpath, self.file)
+
+
+class BuildFile:
+    """
+    Represent the state of a translatable file during the build process.
+    """
+
+    def __init__(self, command, domain, translatable):
+        self.command = command
+        self.domain = domain
+        self.translatable = translatable
+
+    @cached_property
+    def is_templatized(self):
+        if self.domain == "djangojs":
+            return self.command.gettext_version < (0, 18, 3)
+        elif self.domain == "django":
+            file_ext = os.path.splitext(self.translatable.file)[1]
+            return file_ext != ".py"
+        return False
+
+    @cached_property
+    def path(self):
+        return self.translatable.path
+
+    @cached_property
+    def work_path(self):
+        """
+        Path to a file which is being fed into GNU gettext pipeline. This may
+        be either a translatable or its preprocessed version.
+        """
+        if not self.is_templatized:
+            return self.path
+        extension = {
+            "djangojs": "c",
+            "django": "py",
+        }.get(self.domain)
+        filename = "%s.%s" % (self.translatable.file, extension)
+        return os.path.join(self.translatable.dirpath, filename)
+
+    def preprocess(self):
+        """
+        Preprocess (if necessary) a translatable file before passing it to
+        xgettext GNU gettext utility.
+        """
+        if not self.is_templatized:
+            return
+
+        with open(self.path, encoding="utf-8") as fp:
+            src_data = fp.read()
+
+        if self.domain == "djangojs":
+            content = prepare_js_for_gettext(src_data)
+        elif self.domain == "django":
+            content = templatize(src_data, origin=self.path[2:])
+
+        with open(self.work_path, "w", encoding="utf-8") as fp:
+            fp.write(content)
+
+    def postprocess_messages(self, msgs):
+        """
+        Postprocess messages generated by xgettext GNU gettext utility.
+
+        Transform paths as if these messages were generated from original
+        translatable files rather than from preprocessed versions.
+        """
+        if not self.is_templatized:
+            return msgs
+
+        # Remove '.py' suffix
+        if os.name == "nt":
+            # Preserve '.\' prefix on Windows to respect gettext behavior
+            old_path = self.work_path
+            new_path = self.path
+        else:
+            old_path = self.work_path[2:]
+            new_path = self.path[2:]
+
+        return re.sub(
+            r"^(#: .*)(" + re.escape(old_path) + r")",
+            lambda match: match[0].replace(old_path, new_path),
+            msgs,
+            flags=re.MULTILINE,
+        )
+
+    def cleanup(self):
+        """
+        Remove a preprocessed copy of a translatable file (if any).
+        """
+        if self.is_templatized:
+            # This check is needed for the case of a symlinked file and its
+            # source being processed inside a single group (locale dir);
+            # removing either of those two removes both.
+            if os.path.exists(self.work_path):
+                os.unlink(self.work_path)
+
+
+def normalize_eols(raw_contents):
+    """
+    Take a block of raw text that will be passed through str.splitlines() to
+    get universal newlines treatment.
+
+    Return the resulting block of text with normalized `\n` EOL sequences ready
+    to be written to disk using current platform's native EOLs.
+    """
+    lines_list = raw_contents.splitlines()
+    # Ensure last line has its EOL
+    if lines_list and lines_list[-1]:
+        lines_list.append("")
+    return "\n".join(lines_list)
+
+
+def write_pot_file(potfile, msgs):
+    """
+    Write the `potfile` with the `msgs` contents, making sure its format is
+    valid.
+    """
+    pot_lines = msgs.splitlines()
+    if os.path.exists(potfile):
+        # Strip the header
+        lines = dropwhile(len, pot_lines)
+    else:
+        lines = []
+        found, header_read = False, False
+        for line in pot_lines:
+            if not found and not header_read:
+                if "charset=CHARSET" in line:
+                    found = True
+                    line = line.replace("charset=CHARSET", "charset=UTF-8")
+            if not line and not found:
+                header_read = True
+            lines.append(line)
+    msgs = "\n".join(lines)
+    # Force newlines of POT files to '\n' to work around
+    # https://savannah.gnu.org/bugs/index.php?52395
+    with open(potfile, "a", encoding="utf-8", newline="\n") as fp:
+        fp.write(msgs)
+
+
+def add_arguments(parser):
+    parser.add_argument(
+        "--locale",
+        "-l",
+        default=[],
+        action="append",
+        help=(
+            "Creates or updates the message files for the given locale(s) (e.g. "
+            "pt_BR). Can be used multiple times."
+        ),
+    )
+    parser.add_argument(
+        "--exclude",
+        "-x",
+        default=[],
+        action="append",
+        help="Locales to exclude. Default is none. Can be used multiple times.",
+    )
+    parser.add_argument(
+        "--domain",
+        "-d",
+        default="django",
+        help='The domain of the message files (default: "django").',
+    )
+    parser.add_argument(
+        "--all",
+        "-a",
+        action="store_true",
+        help="Updates the message files for all existing locales.",
+    )
+    parser.add_argument(
+        "--extension",
+        "-e",
+        dest="extensions",
+        action="append",
+        help='The file extension(s) to examine (default: "html,txt,py", or "js" '
+        'if the domain is "djangojs"). Separate multiple extensions with '
+        "commas, or use -e multiple times.",
+    )
+    parser.add_argument(
+        "--symlinks",
+        "-s",
+        action="store_true",
+        help="Follows symlinks to directories when examining source code "
+        "and templates for translation strings.",
+    )
+    parser.add_argument(
+        "--ignore",
+        "-i",
+        action="append",
+        dest="ignore_patterns",
+        default=[],
+        metavar="PATTERN",
+        help="Ignore files or directories matching this glob-style pattern. "
+        "Use multiple times to ignore more.",
+    )
+    parser.add_argument(
+        "--no-default-ignore",
+        action="store_false",
+        dest="use_default_ignore_patterns",
+        help=(
+            "Don't ignore the common glob-style patterns 'CVS', '.*', '*~' and "
+            "'*.pyc'."
+        ),
+    )
+    parser.add_argument(
+        "--no-wrap",
+        action="store_true",
+        help="Don't break long message lines into several lines.",
+    )
+    parser.add_argument(
+        "--no-location",
+        action="store_true",
+        help="Don't write '#: filename:line' lines.",
+    )
+    parser.add_argument(
+        "--add-location",
+        choices=("full", "file", "never"),
+        const="full",
+        nargs="?",
+        help=(
+            "Controls '#: filename:line' lines. If the option is 'full' "
+            "(the default if not given), the lines  include both file name "
+            "and line number. If it's 'file', the line number is omitted. If "
+            "it's 'never', the lines are suppressed (same as --no-location). "
+            "--add-location requires gettext 0.19 or newer."
+        ),
+    )
+    parser.add_argument(
+        "--no-obsolete",
+        action="store_true",
+        help="Remove obsolete message strings.",
+    )
+    parser.add_argument(
+        "--keep-pot",
+        action="store_true",
+        help="Keep .pot file after making messages. Useful when debugging.",
+    )
+class Command(BaseCommand):
+    help = (
+        "Runs over the entire source tree of the current directory and pulls out all "
+        "strings marked for translation. It creates (or updates) a message file in the "
+        "conf/locale (in the django tree) or locale (for projects and applications) "
+        "directory.\n\nYou must run this command with one of either the --locale, "
+        "--exclude, or --all options."
+    )
+
+    translatable_file_class = TranslatableFile
+    build_file_class = BuildFile
+
+    requires_system_checks = []
+
+    msgmerge_options = ["-q", "--backup=none", "--previous", "--update"]
+    msguniq_options = ["--to-code=utf-8"]
+    msgattrib_options = ["--no-obsolete"]
+    xgettext_options = ["--from-code=UTF-8", "--add-comments=Translators"]
+
+
+    def handle(self, *args, **options):
+        locale = options["locale"]
+        exclude = options["exclude"]
+        self.domain = options["domain"]
+        self.verbosity = options["verbosity"]
+        process_all = options["all"]
+        extensions = options["extensions"]
+        self.symlinks = options["symlinks"]
+
+        ignore_patterns = options["ignore_patterns"]
+        if options["use_default_ignore_patterns"]:
+            ignore_patterns += ["CVS", ".*", "*~", "*.pyc"]
+        self.ignore_patterns = list(set(ignore_patterns))
+
+        # Avoid messing with mutable class variables
+        if options["no_wrap"]:
+            self.msgmerge_options = self.msgmerge_options[:] + ["--no-wrap"]
+            self.msguniq_options = self.msguniq_options[:] + ["--no-wrap"]
+            self.msgattrib_options = self.msgattrib_options[:] + ["--no-wrap"]
+            self.xgettext_options = self.xgettext_options[:] + ["--no-wrap"]
+        if options["no_location"]:
+            self.msgmerge_options = self.msgmerge_options[:] + ["--no-location"]
+            self.msguniq_options = self.msguniq_options[:] + ["--no-location"]
+            self.msgattrib_options = self.msgattrib_options[:] + ["--no-location"]
+            self.xgettext_options = self.xgettext_options[:] + ["--no-location"]
+        if options["add_location"]:
+            if self.gettext_version < (0, 19):
+                raise CommandError(
+                    "The --add-location option requires gettext 0.19 or later. "
+                    "You have %s." % ".".join(str(x) for x in self.gettext_version)
+                )
+            arg_add_location = "--add-location=%s" % options["add_location"]
+            self.msgmerge_options = self.msgmerge_options[:] + [arg_add_location]
+            self.msguniq_options = self.msguniq_options[:] + [arg_add_location]
+            self.msgattrib_options = self.msgattrib_options[:] + [arg_add_location]
+            self.xgettext_options = self.xgettext_options[:] + [arg_add_location]
+
+        self.no_obsolete = options["no_obsolete"]
+        self.keep_pot = options["keep_pot"]
+
+        if self.domain not in ("django", "djangojs"):
+            raise CommandError(
+                "currently makemessages only supports domains "
+                "'django' and 'djangojs'"
+            )
+        if self.domain == "djangojs":
+            exts = extensions or ["js"]
+        else:
+            exts = extensions or ["html", "txt", "py"]
+        self.extensions = handle_extensions(exts)
+
+        if (not locale and not exclude and not process_all) or self.domain is None:
+            raise CommandError(
+                "Type '%s help %s' for usage information."
+                % (os.path.basename(sys.argv[0]), sys.argv[1])
+            )
+
+        if self.verbosity > 1:
+            self.stdout.write(
+                "examining files with the extensions: %s"
+                % get_text_list(list(self.extensions), "and")
+            )
+
+        self.invoked_for_django = False
+        self.locale_paths = []
+        self.default_locale_path = None
+        if os.path.isdir(os.path.join("conf", "locale")):
+            self.locale_paths = [os.path.abspath(os.path.join("conf", "locale"))]
+            self.default_locale_path = self.locale_paths[0]
+            self.invoked_for_django = True
+        else:
+            if self.settings_available:
+                self.locale_paths.extend(settings.LOCALE_PATHS)
+            # Allow to run makemessages inside an app dir
+            if os.path.isdir("locale"):
+                self.locale_paths.append(os.path.abspath("locale"))
+            if self.locale_paths:
+                self.default_locale_path = self.locale_paths[0]
+                os.makedirs(self.default_locale_path, exist_ok=True)
+
+        # Build locale list
+        looks_like_locale = re.compile(r"[a-z]{2}")
+        locale_dirs = filter(
+            os.path.isdir, glob.glob("%s/*" % self.default_locale_path)
+        )
+        all_locales = [
+            lang_code
+            for lang_code in map(os.path.basename, locale_dirs)
+            if looks_like_locale.match(lang_code)
+        ]
+
+        # Account for excluded locales
+        if process_all:
+            locales = all_locales
+        else:
+            locales = locale or all_locales
+            locales = set(locales).difference(exclude)
+
+        if locales:
+            check_programs("msguniq", "msgmerge", "msgattrib")
+
+        check_programs("xgettext")
+
+        try:
+            potfiles = self.build_potfiles()
+
+            # Build po files for each selected locale
+            for locale in locales:
+                if not is_valid_locale(locale):
+                    # Try to guess what valid locale it could be
+                    # Valid examples are: en_GB, shi_Latn_MA and nl_NL-x-informal
+
+                    # Search for characters followed by a non character (i.e. separator)
+                    match = re.match(
+                        r"^(?P<language>[a-zA-Z]+)"
+                        r"(?P<separator>[^a-zA-Z])"
+                        r"(?P<territory>.+)$",
+                        locale,
+                    )
+                    if match:
+                        locale_parts = match.groupdict()
+                        language = locale_parts["language"].lower()
+                        territory = (
+                            locale_parts["territory"][:2].upper()
+                            + locale_parts["territory"][2:]
+                        )
+                        proposed_locale = f"{language}_{territory}"
+                    else:
+                        # It could be a language in uppercase
+                        proposed_locale = locale.lower()
+
+                    # Recheck if the proposed locale is valid
+                    if is_valid_locale(proposed_locale):
+                        self.stdout.write(
+                            "invalid locale %s, did you mean %s?"
+                            % (
+                                locale,
+                                proposed_locale,
+                            ),
+                        )
+                    else:
+                        self.stdout.write("invalid locale %s" % locale)
+
+                    continue
+                if self.verbosity > 0:
+                    self.stdout.write("processing locale %s" % locale)
+                for potfile in potfiles:
+                    self.write_po_file(potfile, locale)
+        finally:
+            if not self.keep_pot:
+                self.remove_potfiles()
+
+    @cached_property
+    def gettext_version(self):
+        # Gettext tools will output system-encoded bytestrings instead of UTF-8,
+        # when looking up the version. It's especially a problem on Windows.
+        out, err, status = popen_wrapper(
+            ["xgettext", "--version"],
+            stdout_encoding=DEFAULT_LOCALE_ENCODING,
+        )
+        m = re.search(r"(\d+)\.(\d+)\.?(\d+)?", out)
+        if m:
+            return tuple(int(d) for d in m.groups() if d is not None)
+        else:
+            raise CommandError("Unable to get gettext version. Is it installed?")
+
+    @cached_property
+    def settings_available(self):
+        try:
+            settings.LOCALE_PATHS
+        except ImproperlyConfigured:
+            if self.verbosity > 1:
+                self.stderr.write("Running without configured settings.")
+            return False
+        return True
+
+    def build_potfiles(self):
+        """
+        Build pot files and apply msguniq to them.
+        """
+        file_list = self.find_files(".")
+        self.remove_potfiles()
+        self.process_files(file_list)
+        potfiles = []
+        for path in self.locale_paths:
+            potfile = os.path.join(path, "%s.pot" % self.domain)
+            if not os.path.exists(potfile):
+                continue
+            args = ["msguniq"] + self.msguniq_options + [potfile]
+            msgs, errors, status = popen_wrapper(args)
+            if errors:
+                if status != STATUS_OK:
+                    raise CommandError(
+                        "errors happened while running msguniq\n%s" % errors
+                    )
+                elif self.verbosity > 0:
+                    self.stdout.write(errors)
+            msgs = normalize_eols(msgs)
+            with open(potfile, "w", encoding="utf-8") as fp:
+                fp.write(msgs)
+            potfiles.append(potfile)
+        return potfiles
+
+    def remove_potfiles(self):
+        for path in self.locale_paths:
+            pot_path = os.path.join(path, "%s.pot" % self.domain)
+            if os.path.exists(pot_path):
+                os.unlink(pot_path)
+
+    def find_files(self, root):
+        """
+        Get all files in the given root. Also check that there is a matching
+        locale dir for each file.
+        """
+        all_files = []
+        ignored_roots = []
+        if self.settings_available:
+            ignored_roots = [
+                os.path.normpath(p)
+                for p in (settings.MEDIA_ROOT, settings.STATIC_ROOT)
+                if p
+            ]
+        for dirpath, dirnames, filenames in os.walk(
+            root, topdown=True, followlinks=self.symlinks
+        ):
+            for dirname in dirnames[:]:
+                if (
+                    is_ignored_path(
+                        os.path.normpath(os.path.join(dirpath, dirname)),
+                        self.ignore_patterns,
+                    )
+                    or os.path.join(os.path.abspath(dirpath), dirname) in ignored_roots
+                ):
+                    dirnames.remove(dirname)
+                    if self.verbosity > 1:
+                        self.stdout.write("ignoring directory %s" % dirname)
+                elif dirname == "locale":
+                    dirnames.remove(dirname)
+                    self.locale_paths.insert(
+                        0, os.path.join(os.path.abspath(dirpath), dirname)
+                    )
+            for filename in filenames:
+                file_path = os.path.normpath(os.path.join(dirpath, filename))
+                file_ext = os.path.splitext(filename)[1]
+                if file_ext not in self.extensions or is_ignored_path(
+                    file_path, self.ignore_patterns
+                ):
+                    if self.verbosity > 1:
+                        self.stdout.write(
+                            "ignoring file %s in %s" % (filename, dirpath)
+                        )
+                else:
+                    locale_dir = None
+                    for path in self.locale_paths:
+                        if os.path.abspath(dirpath).startswith(os.path.dirname(path)):
+                            locale_dir = path
+                            break
+                    locale_dir = locale_dir or self.default_locale_path or NO_LOCALE_DIR
+                    all_files.append(
+                        self.translatable_file_class(dirpath, filename, locale_dir)
+                    )
+        return sorted(all_files)
+
+    def process_files(self, file_list):
+        """
+        Group translatable files by locale directory and run pot file build
+        process for each group.
+        """
+        file_groups = {}
+        for translatable in file_list:
+            file_group = file_groups.setdefault(translatable.locale_dir, [])
+            file_group.append(translatable)
+        for locale_dir, files in file_groups.items():
+            self.process_locale_dir(locale_dir, files)
+
+    def process_locale_dir(self, locale_dir, files):
+        """
+        Extract translatable literals from the specified files, creating or
+        updating the POT file for a given locale directory.
+
+        Use the xgettext GNU gettext utility.
+        """
+        build_files = []
+        for translatable in files:
+            if self.verbosity > 1:
+                self.stdout.write(
+                    "processing file %s in %s"
+                    % (translatable.file, translatable.dirpath)
+                )
+            if self.domain not in ("djangojs", "django"):
+                continue
+            build_file = self.build_file_class(self, self.domain, translatable)
+            try:
+                build_file.preprocess()
+            except UnicodeDecodeError as e:
+                self.stdout.write(
+                    "UnicodeDecodeError: skipped file %s in %s (reason: %s)"
+                    % (
+                        translatable.file,
+                        translatable.dirpath,
+                        e,
+                    )
+                )
+                continue
+            except BaseException:
+                # Cleanup before exit.
+                for build_file in build_files:
+                    build_file.cleanup()
+                raise
+            build_files.append(build_file)
+
+        if self.domain == "djangojs":
+            is_templatized = build_file.is_templatized
+            args = [
+                "xgettext",
+                "-d",
+                self.domain,
+                "--language=%s" % ("C" if is_templatized else "JavaScript",),
+                "--keyword=gettext_noop",
+                "--keyword=gettext_lazy",
+                "--keyword=ngettext_lazy:1,2",
+                "--keyword=pgettext:1c,2",
+                "--keyword=npgettext:1c,2,3",
+                "--output=-",
+            ]
+        elif self.domain == "django":
+            args = [
+                "xgettext",
+                "-d",
+                self.domain,
+                "--language=Python",
+                "--keyword=gettext_noop",
+                "--keyword=gettext_lazy",
+                "--keyword=ngettext_lazy:1,2",
+                "--keyword=pgettext:1c,2",
+                "--keyword=npgettext:1c,2,3",
+                "--keyword=pgettext_lazy:1c,2",
+                "--keyword=npgettext_lazy:1c,2,3",
+                "--output=-",
+            ]
+        else:
+            return
+
+        input_files = [bf.work_path for bf in build_files]
+        with NamedTemporaryFile(mode="w+") as input_files_list:
+            input_files_list.write("\n".join(input_files))
+            input_files_list.flush()
+            args.extend(["--files-from", input_files_list.name])
+            args.extend(self.xgettext_options)
+            msgs, errors, status = popen_wrapper(args)
+
+        if errors:
+            if status != STATUS_OK:
+                for build_file in build_files:
+                    build_file.cleanup()
+                raise CommandError(
+                    "errors happened while running xgettext on %s\n%s"
+                    % ("\n".join(input_files), errors)
+                )
+            elif self.verbosity > 0:
+                # Print warnings
+                self.stdout.write(errors)
+
+        if msgs:
+            if locale_dir is NO_LOCALE_DIR:
+                for build_file in build_files:
+                    build_file.cleanup()
+                file_path = os.path.normpath(build_files[0].path)
+                raise CommandError(
+                    "Unable to find a locale path to store translations for "
+                    "file %s. Make sure the 'locale' directory exists in an "
+                    "app or LOCALE_PATHS setting is set." % file_path
+                )
+            for build_file in build_files:
+                msgs = build_file.postprocess_messages(msgs)
+            potfile = os.path.join(locale_dir, "%s.pot" % self.domain)
+            write_pot_file(potfile, msgs)
+
+        for build_file in build_files:
+            build_file.cleanup()
+
+    def write_po_file(self, potfile, locale):
+        """
+        Create or update the PO file for self.domain and `locale`.
+        Use contents of the existing `potfile`.
+
+        Use msgmerge and msgattrib GNU gettext utilities.
+        """
+        basedir = os.path.join(os.path.dirname(potfile), locale, "LC_MESSAGES")
+        os.makedirs(basedir, exist_ok=True)
+        pofile = os.path.join(basedir, "%s.po" % self.domain)
+
+        if os.path.exists(pofile):
+            args = ["msgmerge"] + self.msgmerge_options + [pofile, potfile]
+            _, errors, status = popen_wrapper(args)
+            if errors:
+                if status != STATUS_OK:
+                    raise CommandError(
+                        "errors happened while running msgmerge\n%s" % errors
+                    )
+                elif self.verbosity > 0:
+                    self.stdout.write(errors)
+            msgs = Path(pofile).read_text(encoding="utf-8")
+        else:
+            with open(potfile, encoding="utf-8") as fp:
+                msgs = fp.read()
+            if not self.invoked_for_django:
+                msgs = self.copy_plural_forms(msgs, locale)
+        msgs = normalize_eols(msgs)
+        msgs = msgs.replace(
+            "#. #-#-#-#-#  %s.pot (PACKAGE VERSION)  #-#-#-#-#\n" % self.domain, ""
+        )
+        with open(pofile, "w", encoding="utf-8") as fp:
+            fp.write(msgs)
+
+        if self.no_obsolete:
+            args = ["msgattrib"] + self.msgattrib_options + ["-o", pofile, pofile]
+            msgs, errors, status = popen_wrapper(args)
+            if errors:
+                if status != STATUS_OK:
+                    raise CommandError(
+                        "errors happened while running msgattrib\n%s" % errors
+                    )
+                elif self.verbosity > 0:
+                    self.stdout.write(errors)
+
+    def copy_plural_forms(self, msgs, locale):
+        """
+        Copy plural forms header contents from a Django catalog of locale to
+        the msgs string, inserting it at the right place. msgs should be the
+        contents of a newly created .po file.
+        """
+        django_dir = os.path.normpath(os.path.join(os.path.dirname(django.__file__)))
+        if self.domain == "djangojs":
+            domains = ("djangojs", "django")
+        else:
+            domains = ("django",)
+        for domain in domains:
+            django_po = os.path.join(
+                django_dir, "conf", "locale", locale, "LC_MESSAGES", "%s.po" % domain
+            )
+            if os.path.exists(django_po):
+                with open(django_po, encoding="utf-8") as fp:
+                    m = plural_forms_re.search(fp.read())
+                if m:
+                    plural_form_line = m["value"]
+                    if self.verbosity > 1:
+                        self.stdout.write("copying plural forms: %s" % plural_form_line)
+                    lines = []
+                    found = False
+                    for line in msgs.splitlines():
+                        if not found and (not line or plural_forms_re.search(line)):
+                            line = plural_form_line
+                            found = True
+                        lines.append(line)
+                    msgs = "\n".join(lines)
+                    break
+        return msgs
diff --git a/tests/corpus/.refactoring-benchmark/expected.migrate.py b/tests/corpus/.refactoring-benchmark/expected.migrate.py
new file mode 100644
index 0000000..1541843
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/expected.migrate.py
@@ -0,0 +1,511 @@
+import sys
+import time
+from importlib import import_module
+
+from django.apps import apps
+from django.core.management.base import BaseCommand, CommandError, no_translations
+from django.core.management.sql import emit_post_migrate_signal, emit_pre_migrate_signal
+from django.db import DEFAULT_DB_ALIAS, connections, router
+from django.db.migrations.autodetector import MigrationAutodetector
+from django.db.migrations.executor import MigrationExecutor
+from django.db.migrations.loader import AmbiguityError
+from django.db.migrations.state import ModelState, ProjectState
+from django.utils.module_loading import module_has_submodule
+from django.utils.text import Truncator
+
+
+class Command(BaseCommand):
+    help = (
+        "Updates database schema. Manages both apps with migrations and those without."
+    )
+    requires_system_checks = []
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "--skip-checks",
+            action="store_true",
+            help="Skip system checks.",
+        )
+        parser.add_argument(
+            "app_label",
+            nargs="?",
+            help="App label of an application to synchronize the state.",
+        )
+        parser.add_argument(
+            "migration_name",
+            nargs="?",
+            help="Database state will be brought to the state after that "
+            'migration. Use the name "zero" to unapply all migrations.',
+        )
+        parser.add_argument(
+            "--noinput",
+            "--no-input",
+            action="store_false",
+            dest="interactive",
+            help="Tells Django to NOT prompt the user for input of any kind.",
+        )
+        parser.add_argument(
+            "--database",
+            default=DEFAULT_DB_ALIAS,
+            help=(
+                'Nominates a database to synchronize. Defaults to the "default" '
+                "database."
+            ),
+        )
+        parser.add_argument(
+            "--fake",
+            action="store_true",
+            help="Mark migrations as run without actually running them.",
+        )
+        parser.add_argument(
+            "--fake-initial",
+            action="store_true",
+            help=(
+                "Detect if tables already exist and fake-apply initial migrations if "
+                "so. Make sure that the current database schema matches your initial "
+                "migration before using this flag. Django will only check for an "
+                "existing table name."
+            ),
+        )
+        parser.add_argument(
+            "--plan",
+            action="store_true",
+            help="Shows a list of the migration actions that will be performed.",
+        )
+        parser.add_argument(
+            "--run-syncdb",
+            action="store_true",
+            help="Creates tables for apps without migrations.",
+        )
+        parser.add_argument(
+            "--check",
+            action="store_true",
+            dest="check_unapplied",
+            help=(
+                "Exits with a non-zero status if unapplied migrations exist and does "
+                "not actually apply migrations."
+            ),
+        )
+        parser.add_argument(
+            "--prune",
+            action="store_true",
+            dest="prune",
+            help="Delete nonexistent migrations from the django_migrations table.",
+        )
+
+    @no_translations
+    def handle(self, *args, **options):
+        database = options["database"]
+        if not options["skip_checks"]:
+            self.check(databases=[database])
+
+        self.verbosity = options["verbosity"]
+        self.interactive = options["interactive"]
+
+        # Import the 'management' module within each installed app, to register
+        # dispatcher events.
+        for app_config in apps.get_app_configs():
+            if module_has_submodule(app_config.module, "management"):
+                import_module(".management", app_config.name)
+
+        # Get the database we're operating from
+        connection = connections[database]
+
+        # Hook for backends needing any database preparation
+        connection.prepare_database()
+        # Work out which apps have migrations and which do not
+        executor = MigrationExecutor(connection, self.migration_progress_callback)
+
+        # Raise an error if any migrations are applied before their dependencies.
+        executor.loader.check_consistent_history(connection)
+
+        # Before anything else, see if there's conflicting apps and drop out
+        # hard if there are any
+        conflicts = executor.loader.detect_conflicts()
+        if conflicts:
+            name_str = "; ".join(
+                "%s in %s" % (", ".join(names), app) for app, names in conflicts.items()
+            )
+            raise CommandError(
+                "Conflicting migrations detected; multiple leaf nodes in the "
+                "migration graph: (%s).\nTo fix them run "
+                "'python manage.py makemigrations --merge'" % name_str
+            )
+
+        # If they supplied command line arguments, work out what they mean.
+        run_syncdb = options["run_syncdb"]
+        target_app_labels_only = True
+        if options["app_label"]:
+            # Validate app_label.
+            app_label = options["app_label"]
+            try:
+                apps.get_app_config(app_label)
+            except LookupError as err:
+                raise CommandError(str(err))
+            if run_syncdb:
+                if app_label in executor.loader.migrated_apps:
+                    raise CommandError(
+                        "Can't use run_syncdb with app '%s' as it has migrations."
+                        % app_label
+                    )
+            elif app_label not in executor.loader.migrated_apps:
+                raise CommandError("App '%s' does not have migrations." % app_label)
+
+        if options["app_label"] and options["migration_name"]:
+            migration_name = options["migration_name"]
+            if migration_name == "zero":
+                targets = [(app_label, None)]
+            else:
+                try:
+                    migration = executor.loader.get_migration_by_prefix(
+                        app_label, migration_name
+                    )
+                except AmbiguityError:
+                    raise CommandError(
+                        "More than one migration matches '%s' in app '%s'. "
+                        "Please be more specific." % (migration_name, app_label)
+                    )
+                except KeyError:
+                    raise CommandError(
+                        "Cannot find a migration matching '%s' from app '%s'."
+                        % (migration_name, app_label)
+                    )
+                target = (app_label, migration.name)
+                # Partially applied squashed migrations are not included in the
+                # graph, use the last replacement instead.
+                if (
+                    target not in executor.loader.graph.nodes
+                    and target in executor.loader.replacements
+                ):
+                    incomplete_migration = executor.loader.replacements[target]
+                    target = incomplete_migration.replaces[-1]
+                targets = [target]
+            target_app_labels_only = False
+        elif options["app_label"]:
+            targets = [
+                key for key in executor.loader.graph.leaf_nodes() if key[0] == app_label
+            ]
+        else:
+            targets = executor.loader.graph.leaf_nodes()
+
+        if options["prune"]:
+            if not options["app_label"]:
+                raise CommandError(
+                    "Migrations can be pruned only when an app is specified."
+                )
+            if self.verbosity > 0:
+                self.stdout.write("Pruning migrations:", self.style.MIGRATE_HEADING)
+            to_prune = set(executor.loader.applied_migrations) - set(
+                executor.loader.disk_migrations
+            )
+            squashed_migrations_with_deleted_replaced_migrations = [
+                migration_key
+                for migration_key, migration_obj in executor.loader.replacements.items()
+                if any(replaced in to_prune for replaced in migration_obj.replaces)
+            ]
+            if squashed_migrations_with_deleted_replaced_migrations:
+                self.stdout.write(
+                    self.style.NOTICE(
+                        "  Cannot use --prune because the following squashed "
+                        "migrations have their 'replaces' attributes and may not "
+                        "be recorded as applied:"
+                    )
+                )
+                for migration in squashed_migrations_with_deleted_replaced_migrations:
+                    app, name = migration
+                    self.stdout.write(f"    {app}.{name}")
+                self.stdout.write(
+                    self.style.NOTICE(
+                        "  Re-run 'manage.py migrate' if they are not marked as "
+                        "applied, and remove 'replaces' attributes in their "
+                        "Migration classes."
+                    )
+                )
+            else:
+                to_prune = sorted(
+                    migration for migration in to_prune if migration[0] == app_label
+                )
+                if to_prune:
+                    for migration in to_prune:
+                        app, name = migration
+                        if self.verbosity > 0:
+                            self.stdout.write(
+                                self.style.MIGRATE_LABEL(f"  Pruning {app}.{name}"),
+                                ending="",
+                            )
+                        executor.recorder.record_unapplied(app, name)
+                        if self.verbosity > 0:
+                            self.stdout.write(self.style.SUCCESS(" OK"))
+                elif self.verbosity > 0:
+                    self.stdout.write("  No migrations to prune.")
+
+        plan = executor.migration_plan(targets)
+
+        if options["plan"]:
+            self.stdout.write("Planned operations:", self.style.MIGRATE_LABEL)
+            if not plan:
+                self.stdout.write("  No planned migration operations.")
+            else:
+                for migration, backwards in plan:
+                    self.stdout.write(str(migration), self.style.MIGRATE_HEADING)
+                    for operation in migration.operations:
+                        message, is_error = self.describe_operation(
+                            operation, backwards
+                        )
+                        style = self.style.WARNING if is_error else None
+                        self.stdout.write("    " + message, style)
+                if options["check_unapplied"]:
+                    sys.exit(1)
+            return
+        if options["check_unapplied"]:
+            if plan:
+                sys.exit(1)
+            return
+        if options["prune"]:
+            return
+
+        # At this point, ignore run_syncdb if there aren't any apps to sync.
+        run_syncdb = options["run_syncdb"] and executor.loader.unmigrated_apps
+        # Print some useful info
+        if self.verbosity >= 1:
+            self.stdout.write(self.style.MIGRATE_HEADING("Operations to perform:"))
+            if run_syncdb:
+                if options["app_label"]:
+                    self.stdout.write(
+                        self.style.MIGRATE_LABEL(
+                            "  Synchronize unmigrated app: %s" % app_label
+                        )
+                    )
+                else:
+                    self.stdout.write(
+                        self.style.MIGRATE_LABEL("  Synchronize unmigrated apps: ")
+                        + (", ".join(sorted(executor.loader.unmigrated_apps)))
+                    )
+            if target_app_labels_only:
+                self.stdout.write(
+                    self.style.MIGRATE_LABEL("  Apply all migrations: ")
+                    + (", ".join(sorted({a for a, n in targets})) or "(none)")
+                )
+            else:
+                if targets[0][1] is None:
+                    self.stdout.write(
+                        self.style.MIGRATE_LABEL("  Unapply all migrations: ")
+                        + str(targets[0][0])
+                    )
+                else:
+                    self.stdout.write(
+                        self.style.MIGRATE_LABEL("  Target specific migration: ")
+                        + "%s, from %s" % (targets[0][1], targets[0][0])
+                    )
+
+        pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
+        pre_migrate_apps = pre_migrate_state.apps
+        emit_pre_migrate_signal(
+            self.verbosity,
+            self.interactive,
+            connection.alias,
+            stdout=self.stdout,
+            apps=pre_migrate_apps,
+            plan=plan,
+        )
+
+        # Run the syncdb phase.
+        if run_syncdb:
+            if self.verbosity >= 1:
+                self.stdout.write(
+                    self.style.MIGRATE_HEADING("Synchronizing apps without migrations:")
+                )
+            if options["app_label"]:
+                self.sync_apps(connection, [app_label])
+            else:
+                self.sync_apps(connection, executor.loader.unmigrated_apps)
+
+        # Migrate!
+        if self.verbosity >= 1:
+            self.stdout.write(self.style.MIGRATE_HEADING("Running migrations:"))
+        if not plan:
+            if self.verbosity >= 1:
+                self.stdout.write("  No migrations to apply.")
+                # If there's changes that aren't in migrations yet, tell them
+                # how to fix it.
+                autodetector = MigrationAutodetector(
+                    executor.loader.project_state(),
+                    ProjectState.from_apps(apps),
+                )
+                changes = autodetector.changes(graph=executor.loader.graph)
+                if changes:
+                    self.stdout.write(
+                        self.style.NOTICE(
+                            "  Your models in app(s): %s have changes that are not "
+                            "yet reflected in a migration, and so won't be "
+                            "applied." % ", ".join(repr(app) for app in sorted(changes))
+                        )
+                    )
+                    self.stdout.write(
+                        self.style.NOTICE(
+                            "  Run 'manage.py makemigrations' to make new "
+                            "migrations, and then re-run 'manage.py migrate' to "
+                            "apply them."
+                        )
+                    )
+            fake = False
+            fake_initial = False
+        else:
+            fake = options["fake"]
+            fake_initial = options["fake_initial"]
+        post_migrate_state = executor.migrate(
+            targets,
+            plan=plan,
+            state=pre_migrate_state.clone(),
+            fake=fake,
+            fake_initial=fake_initial,
+        )
+        # post_migrate signals have access to all models. Ensure that all models
+        # are reloaded in case any are delayed.
+        post_migrate_state.clear_delayed_apps_cache()
+        post_migrate_apps = post_migrate_state.apps
+
+        # Re-render models of real apps to include relationships now that
+        # we've got a final state. This wouldn't be necessary if real apps
+        # models were rendered with relationships in the first place.
+        with post_migrate_apps.bulk_update():
+            model_keys = []
+            for model_state in post_migrate_apps.real_models:
+                model_key = model_state.app_label, model_state.name_lower
+                model_keys.append(model_key)
+                post_migrate_apps.unregister_model(*model_key)
+        post_migrate_apps.render_multiple(
+            [ModelState.from_model(apps.get_model(*model)) for model in model_keys]
+        )
+
+        # Send the post_migrate signal, so individual apps can do whatever they need
+        # to do at this point.
+        emit_post_migrate_signal(
+            self.verbosity,
+            self.interactive,
+            connection.alias,
+            stdout=self.stdout,
+            apps=post_migrate_apps,
+            plan=plan,
+        )
+
+    def migration_progress_callback(self, action, migration=None, fake=False):
+        if self.verbosity >= 1:
+            compute_time = self.verbosity > 1
+            if action == "apply_start":
+                if compute_time:
+                    self.start = time.monotonic()
+                self.stdout.write("  Applying %s..." % migration, ending="")
+                self.stdout.flush()
+            elif action == "apply_success":
+                elapsed = (
+                    " (%.3fs)" % (time.monotonic() - self.start) if compute_time else ""
+                )
+                if fake:
+                    self.stdout.write(self.style.SUCCESS(" FAKED" + elapsed))
+                else:
+                    self.stdout.write(self.style.SUCCESS(" OK" + elapsed))
+            elif action == "unapply_start":
+                if compute_time:
+                    self.start = time.monotonic()
+                self.stdout.write("  Unapplying %s..." % migration, ending="")
+                self.stdout.flush()
+            elif action == "unapply_success":
+                elapsed = (
+                    " (%.3fs)" % (time.monotonic() - self.start) if compute_time else ""
+                )
+                if fake:
+                    self.stdout.write(self.style.SUCCESS(" FAKED" + elapsed))
+                else:
+                    self.stdout.write(self.style.SUCCESS(" OK" + elapsed))
+            elif action == "render_start":
+                if compute_time:
+                    self.start = time.monotonic()
+                self.stdout.write("  Rendering model states...", ending="")
+                self.stdout.flush()
+            elif action == "render_success":
+                elapsed = (
+                    " (%.3fs)" % (time.monotonic() - self.start) if compute_time else ""
+                )
+                self.stdout.write(self.style.SUCCESS(" DONE" + elapsed))
+
+    def sync_apps(self, connection, app_labels):
+        """Run the old syncdb-style operation on a list of app_labels."""
+        with connection.cursor() as cursor:
+            tables = connection.introspection.table_names(cursor)
+
+        # Build the manifest of apps and models that are to be synchronized.
+        all_models = [
+            (
+                app_config.label,
+                router.get_migratable_models(
+                    app_config, connection.alias, include_auto_created=False
+                ),
+            )
+            for app_config in apps.get_app_configs()
+            if app_config.models_module is not None and app_config.label in app_labels
+        ]
+
+        def model_installed(model):
+            opts = model._meta
+            converter = connection.introspection.identifier_converter
+            return not (
+                (converter(opts.db_table) in tables)
+                or (
+                    opts.auto_created
+                    and converter(opts.auto_created._meta.db_table) in tables
+                )
+            )
+
+        manifest = {
+            app_name: list(filter(model_installed, model_list))
+            for app_name, model_list in all_models
+        }
+
+        # Create the tables for each model
+        if self.verbosity >= 1:
+            self.stdout.write("  Creating tables...")
+        with connection.schema_editor() as editor:
+            for app_name, model_list in manifest.items():
+                for model in model_list:
+                    # Never install unmanaged models, etc.
+                    if not model._meta.can_migrate(connection):
+                        continue
+                    if self.verbosity >= 3:
+                        self.stdout.write(
+                            "    Processing %s.%s model"
+                            % (app_name, model._meta.object_name)
+                        )
+                    if self.verbosity >= 1:
+                        self.stdout.write(
+                            "    Creating table %s" % model._meta.db_table
+                        )
+                    editor.create_model(model)
+
+            # Deferred SQL is executed when exiting the editor's context.
+            if self.verbosity >= 1:
+                self.stdout.write("    Running deferred SQL...")
+
+    @staticmethod
+    def describe_operation(operation, backwards):
+        """Return a string that describes a migration operation for --plan."""
+        prefix = ""
+        is_error = False
+        if hasattr(operation, "code"):
+            code = operation.reverse_code if backwards else operation.code
+            action = (code.__doc__ or "") if code else None
+        elif hasattr(operation, "sql"):
+            action = operation.reverse_sql if backwards else operation.sql
+        else:
+            action = ""
+            if backwards:
+                prefix = "Undo "
+        if action is not None:
+            action = str(action).replace("\n", "")
+        elif backwards:
+            action = "IRREVERSIBLE"
+            is_error = True
+        if action:
+            action = " -> " + action
+        truncated = Truncator(action)
+        return prefix + operation.describe() + truncated.chars(40), is_error
diff --git a/tests/corpus/.refactoring-benchmark/expected.operations.py b/tests/corpus/.refactoring-benchmark/expected.operations.py
new file mode 100644
index 0000000..78f9981
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/expected.operations.py
@@ -0,0 +1,726 @@
+import datetime
+import uuid
+from functools import lru_cache
+
+from django.conf import settings
+from django.db import DatabaseError, NotSupportedError
+from django.db.backends.base.operations import BaseDatabaseOperations
+from django.db.backends.utils import split_tzname_delta, strip_quotes, truncate_name
+from django.db.models import AutoField, Exists, ExpressionWrapper, Lookup
+from django.db.models.expressions import RawSQL
+from django.db.models.sql.where import WhereNode
+from django.utils import timezone
+from django.utils.encoding import force_bytes, force_str
+from django.utils.functional import cached_property
+from django.utils.regex_helper import _lazy_re_compile
+
+from .base import Database
+from .utils import BulkInsertMapper, InsertVar, Oracle_datetime
+
+
+class DatabaseOperations(BaseDatabaseOperations):
+    # Oracle uses NUMBER(5), NUMBER(11), and NUMBER(19) for integer fields.
+    # SmallIntegerField uses NUMBER(11) instead of NUMBER(5), which is used by
+    # SmallAutoField, to preserve backward compatibility.
+    integer_field_ranges = {
+        "SmallIntegerField": (-99999999999, 99999999999),
+        "IntegerField": (-99999999999, 99999999999),
+        "BigIntegerField": (-9999999999999999999, 9999999999999999999),
+        "PositiveBigIntegerField": (0, 9999999999999999999),
+        "PositiveSmallIntegerField": (0, 99999999999),
+        "PositiveIntegerField": (0, 99999999999),
+        "SmallAutoField": (-99999, 99999),
+        "AutoField": (-99999999999, 99999999999),
+        "BigAutoField": (-9999999999999999999, 9999999999999999999),
+    }
+    set_operators = {**BaseDatabaseOperations.set_operators, "difference": "MINUS"}
+
+    # TODO: colorize this SQL code with style.SQL_KEYWORD(), etc.
+    _sequence_reset_sql = """
+DECLARE
+    table_value integer;
+    seq_value integer;
+    seq_name user_tab_identity_cols.sequence_name%%TYPE;
+BEGIN
+    BEGIN
+        SELECT sequence_name INTO seq_name FROM user_tab_identity_cols
+        WHERE  table_name = '%(table_name)s' AND
+               column_name = '%(column_name)s';
+        EXCEPTION WHEN NO_DATA_FOUND THEN
+            seq_name := '%(no_autofield_sequence_name)s';
+    END;
+
+    SELECT NVL(MAX(%(column)s), 0) INTO table_value FROM %(table)s;
+    SELECT NVL(last_number - cache_size, 0) INTO seq_value FROM user_sequences
+           WHERE sequence_name = seq_name;
+    WHILE table_value > seq_value LOOP
+        EXECUTE IMMEDIATE 'SELECT "'||seq_name||'".nextval FROM DUAL'
+        INTO seq_value;
+    END LOOP;
+END;
+/"""
+
+    # Oracle doesn't support string without precision; use the max string size.
+    cast_char_field_without_max_length = "NVARCHAR2(2000)"
+    cast_data_types = {
+        "AutoField": "NUMBER(11)",
+        "BigAutoField": "NUMBER(19)",
+        "SmallAutoField": "NUMBER(5)",
+        "TextField": cast_char_field_without_max_length,
+    }
+
+    def cache_key_culling_sql(self):
+        cache_key = self.quote_name("cache_key")
+        return (
+            f"SELECT {cache_key} "
+            f"FROM %s "
+            f"ORDER BY {cache_key} OFFSET %%s ROWS FETCH FIRST 1 ROWS ONLY"
+        )
+
+    # EXTRACT format cannot be passed in parameters.
+    _extract_format_re = _lazy_re_compile(r"[A-Z_]+")
+
+    def date_extract_sql(self, lookup_type, sql, params):
+        extract_sql = f"TO_CHAR({sql}, %s)"
+        extract_param = None
+        if lookup_type == "week_day":
+            # TO_CHAR(field, 'D') returns an integer from 1-7, where 1=Sunday.
+            extract_param = "D"
+        elif lookup_type == "iso_week_day":
+            extract_sql = f"TO_CHAR({sql} - 1, %s)"
+            extract_param = "D"
+        elif lookup_type == "week":
+            # IW = ISO week number
+            extract_param = "IW"
+        elif lookup_type == "quarter":
+            extract_param = "Q"
+        elif lookup_type == "iso_year":
+            extract_param = "IYYY"
+        else:
+            lookup_type = lookup_type.upper()
+            if not self._extract_format_re.fullmatch(lookup_type):
+                raise ValueError(f"Invalid loookup type: {lookup_type!r}")
+            # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/EXTRACT-datetime.html
+            return f"EXTRACT({lookup_type} FROM {sql})", params
+        return extract_sql, (*params, extract_param)
+
+    def date_trunc_sql(self, lookup_type, sql, params, tzname=None):
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/ROUND-and-TRUNC-Date-Functions.html
+        trunc_param = None
+        if lookup_type in ("year", "month"):
+            trunc_param = lookup_type.upper()
+        elif lookup_type == "quarter":
+            trunc_param = "Q"
+        elif lookup_type == "week":
+            trunc_param = "IW"
+        else:
+            return f"TRUNC({sql})", params
+        return f"TRUNC({sql}, %s)", (*params, trunc_param)
+
+    # Oracle crashes with "ORA-03113: end-of-file on communication channel"
+    # if the time zone name is passed in parameter. Use interpolation instead.
+    # https://groups.google.com/forum/#!msg/django-developers/zwQju7hbG78/9l934yelwfsJ
+    # This regexp matches all time zone names from the zoneinfo database.
+    _tzname_re = _lazy_re_compile(r"^[\w/:+-]+$")
+
+    def _prepare_tzname_delta(self, tzname):
+        tzname, sign, offset = split_tzname_delta(tzname)
+        return f"{sign}{offset}" if offset else tzname
+
+    def _convert_sql_to_tz(self, sql, params, tzname):
+        if not (settings.USE_TZ and tzname):
+            return sql, params
+        if not self._tzname_re.match(tzname):
+            raise ValueError("Invalid time zone name: %s" % tzname)
+        # Convert from connection timezone to the local time, returning
+        # TIMESTAMP WITH TIME ZONE and cast it back to TIMESTAMP to strip the
+        # TIME ZONE details.
+        if self.connection.timezone_name != tzname:
+            from_timezone_name = self.connection.timezone_name
+            to_timezone_name = self._prepare_tzname_delta(tzname)
+            return (
+                f"CAST((FROM_TZ({sql}, '{from_timezone_name}') AT TIME ZONE "
+                f"'{to_timezone_name}') AS TIMESTAMP)",
+                params,
+            )
+        return sql, params
+
+    def datetime_cast_date_sql(self, sql, params, tzname):
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        return f"TRUNC({sql})", params
+
+    def datetime_cast_time_sql(self, sql, params, tzname):
+        # Since `TimeField` values are stored as TIMESTAMP change to the
+        # default date and convert the field to the specified timezone.
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        convert_datetime_sql = (
+            f"TO_TIMESTAMP(CONCAT('1900-01-01 ', TO_CHAR({sql}, 'HH24:MI:SS.FF')), "
+            f"'YYYY-MM-DD HH24:MI:SS.FF')"
+        )
+        return (
+            f"CASE WHEN {sql} IS NOT NULL THEN {convert_datetime_sql} ELSE NULL END",
+            (*params, *params),
+        )
+
+    def datetime_extract_sql(self, lookup_type, sql, params, tzname):
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        return self.date_extract_sql(lookup_type, sql, params)
+
+    def datetime_trunc_sql(self, lookup_type, sql, params, tzname):
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/ROUND-and-TRUNC-Date-Functions.html
+        trunc_param = None
+        if lookup_type in ("year", "month"):
+            trunc_param = lookup_type.upper()
+        elif lookup_type == "quarter":
+            trunc_param = "Q"
+        elif lookup_type == "week":
+            trunc_param = "IW"
+        elif lookup_type == "hour":
+            trunc_param = "HH24"
+        elif lookup_type == "minute":
+            trunc_param = "MI"
+        elif lookup_type == "day":
+            return f"TRUNC({sql})", params
+        else:
+            # Cast to DATE removes sub-second precision.
+            return f"CAST({sql} AS DATE)", params
+        return f"TRUNC({sql}, %s)", (*params, trunc_param)
+
+    def time_trunc_sql(self, lookup_type, sql, params, tzname=None):
+        # The implementation is similar to `datetime_trunc_sql` as both
+        # `DateTimeField` and `TimeField` are stored as TIMESTAMP where
+        # the date part of the later is ignored.
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        trunc_param = None
+        if lookup_type == "hour":
+            trunc_param = "HH24"
+        elif lookup_type == "minute":
+            trunc_param = "MI"
+        elif lookup_type == "second":
+            # Cast to DATE removes sub-second precision.
+            return f"CAST({sql} AS DATE)", params
+        return f"TRUNC({sql}, %s)", (*params, trunc_param)
+
+    def get_db_converters(self, expression):
+        converters = super().get_db_converters(expression)
+        internal_type = expression.output_field.get_internal_type()
+        if internal_type in ["JSONField", "TextField"]:
+            converters.append(self.convert_textfield_value)
+        elif internal_type == "BinaryField":
+            converters.append(self.convert_binaryfield_value)
+        elif internal_type == "BooleanField":
+            converters.append(self.convert_booleanfield_value)
+        elif internal_type == "DateTimeField":
+            if settings.USE_TZ:
+                converters.append(self.convert_datetimefield_value)
+        elif internal_type == "DateField":
+            converters.append(self.convert_datefield_value)
+        elif internal_type == "TimeField":
+            converters.append(self.convert_timefield_value)
+        elif internal_type == "UUIDField":
+            converters.append(self.convert_uuidfield_value)
+        # Oracle stores empty strings as null. If the field accepts the empty
+        # string, undo this to adhere to the Django convention of using
+        # the empty string instead of null.
+        if expression.output_field.empty_strings_allowed:
+            converters.append(
+                self.convert_empty_bytes
+                if internal_type == "BinaryField"
+                else self.convert_empty_string
+            )
+        return converters
+
+    def convert_textfield_value(self, value, expression, connection):
+        if isinstance(value, Database.LOB):
+            value = value.read()
+        return value
+
+    def convert_binaryfield_value(self, value, expression, connection):
+        if isinstance(value, Database.LOB):
+            value = force_bytes(value.read())
+        return value
+
+    def convert_booleanfield_value(self, value, expression, connection):
+        if value in (0, 1):
+            value = bool(value)
+        return value
+
+    # cx_Oracle always returns datetime.datetime objects for
+    # DATE and TIMESTAMP columns, but Django wants to see a
+    # python datetime.date, .time, or .datetime.
+
+    def convert_datetimefield_value(self, value, expression, connection):
+        if value is not None:
+            value = timezone.make_aware(value, self.connection.timezone)
+        return value
+
+    def convert_datefield_value(self, value, expression, connection):
+        if isinstance(value, Database.Timestamp):
+            value = value.date()
+        return value
+
+    def convert_timefield_value(self, value, expression, connection):
+        if isinstance(value, Database.Timestamp):
+            value = value.time()
+        return value
+
+    def convert_uuidfield_value(self, value, expression, connection):
+        if value is not None:
+            value = uuid.UUID(value)
+        return value
+
+    @staticmethod
+    def convert_empty_string(value, expression, connection):
+        return "" if value is None else value
+
+    @staticmethod
+    def convert_empty_bytes(value, expression, connection):
+        return b"" if value is None else value
+
+    def deferrable_sql(self):
+        return " DEFERRABLE INITIALLY DEFERRED"
+
+    def fetch_returned_insert_columns(self, cursor, returning_params):
+        columns = []
+        for param in returning_params:
+            value = param.get_value()
+            if value == []:
+                raise DatabaseError(
+                    "The database did not return a new row id. Probably "
+                    '"ORA-1403: no data found" was raised internally but was '
+                    "hidden by the Oracle OCI library (see "
+                    "https://code.djangoproject.com/ticket/28859)."
+                )
+            columns.append(value[0])
+        return tuple(columns)
+
+    def field_cast_sql(self, db_type, internal_type):
+        if db_type and db_type.endswith("LOB") and internal_type != "JSONField":
+            return "DBMS_LOB.SUBSTR(%s)"
+        else:
+            return "%s"
+
+    def no_limit_value(self):
+        return None
+
+    def limit_offset_sql(self, low_mark, high_mark):
+        fetch, offset = self._get_limit_offset_params(low_mark, high_mark)
+        return " ".join(
+            sql
+            for sql in (
+                ("OFFSET %d ROWS" % offset) if offset else None,
+                ("FETCH FIRST %d ROWS ONLY" % fetch) if fetch else None,
+            )
+            if sql
+        )
+
+    def last_executed_query(self, cursor, sql, params):
+        # https://cx-oracle.readthedocs.io/en/latest/api_manual/cursor.html#Cursor.statement
+        # The DB API definition does not define this attribute.
+        statement = cursor.statement
+        # Unlike Psycopg's `query` and MySQLdb`'s `_executed`, cx_Oracle's
+        # `statement` doesn't contain the query parameters. Substitute
+        # parameters manually.
+        if isinstance(params, (tuple, list)):
+            for i, param in enumerate(reversed(params), start=1):
+                param_num = len(params) - i
+                statement = statement.replace(
+                    ":arg%d" % param_num, force_str(param, errors="replace")
+                )
+        elif isinstance(params, dict):
+            for key in sorted(params, key=len, reverse=True):
+                statement = statement.replace(
+                    ":%s" % key, force_str(params[key], errors="replace")
+                )
+        return statement
+
+    def last_insert_id(self, cursor, table_name, pk_name):
+        sq_name = self._get_sequence_name(cursor, strip_quotes(table_name), pk_name)
+        cursor.execute('"%s".currval' % sq_name)
+        return cursor.fetchone()[0]
+
+    def lookup_cast(self, lookup_type, internal_type=None):
+        if lookup_type in ("iexact", "icontains", "istartswith", "iendswith"):
+            return "UPPER(%s)"
+        if internal_type == "JSONField" and lookup_type == "exact":
+            return "DBMS_LOB.SUBSTR(%s)"
+        return "%s"
+
+    def max_in_list_size(self):
+        return 1000
+
+    def max_name_length(self):
+        return 30
+
+    def pk_default_value(self):
+        return "NULL"
+
+    def prep_for_iexact_query(self, x):
+        return x
+
+    def process_clob(self, value):
+        if value is None:
+            return ""
+        return value.read()
+
+    def quote_name(self, name):
+        # SQL92 requires delimited (quoted) names to be case-sensitive.  When
+        # not quoted, Oracle has case-insensitive behavior for identifiers, but
+        # always defaults to uppercase.
+        # We simplify things by making Oracle identifiers always uppercase.
+        if not name.startswith('"') and not name.endswith('"'):
+            name = '"%s"' % truncate_name(name, self.max_name_length())
+        # Oracle puts the query text into a (query % args) construct, so % signs
+        # in names need to be escaped. The '%%' will be collapsed back to '%' at
+        # that stage so we aren't really making the name longer here.
+        name = name.replace("%", "%%")
+        return name.upper()
+
+    def regex_lookup(self, lookup_type):
+        if lookup_type == "regex":
+            match_option = "'c'"
+        else:
+            match_option = "'i'"
+        return "REGEXP_LIKE(%%s, %%s, %s)" % match_option
+
+    def return_insert_columns(self, fields):
+        if not fields:
+            return "", ()
+        field_names = []
+        params = []
+        for field in fields:
+            field_names.append(
+                "%s.%s"
+                % (
+                    self.quote_name(field.model._meta.db_table),
+                    self.quote_name(field.column),
+                )
+            )
+            params.append(InsertVar(field))
+        return "RETURNING %s INTO %s" % (
+            ", ".join(field_names),
+            ", ".join(["%s"] * len(params)),
+        ), tuple(params)
+
+    def __foreign_key_constraints(self, table_name, recursive):
+        with self.connection.cursor() as cursor:
+            if recursive:
+                cursor.execute(
+                    """
+                    SELECT
+                        user_tables.table_name, rcons.constraint_name
+                    FROM
+                        user_tables
+                    JOIN
+                        user_constraints cons
+                        ON (user_tables.table_name = cons.table_name
+                        AND cons.constraint_type = ANY('P', 'U'))
+                    LEFT JOIN
+                        user_constraints rcons
+                        ON (user_tables.table_name = rcons.table_name
+                        AND rcons.constraint_type = 'R')
+                    START WITH user_tables.table_name = UPPER(%s)
+                    CONNECT BY
+                        NOCYCLE PRIOR cons.constraint_name = rcons.r_constraint_name
+                    GROUP BY
+                        user_tables.table_name, rcons.constraint_name
+                    HAVING user_tables.table_name != UPPER(%s)
+                    ORDER BY MAX(level) DESC
+                    """,
+                    (table_name, table_name),
+                )
+            else:
+                cursor.execute(
+                    """
+                    SELECT
+                        cons.table_name, cons.constraint_name
+                    FROM
+                        user_constraints cons
+                    WHERE
+                        cons.constraint_type = 'R'
+                        AND cons.table_name = UPPER(%s)
+                    """,
+                    (table_name,),
+                )
+            return cursor.fetchall()
+
+    @cached_property
+    def _foreign_key_constraints(self):
+        # 512 is large enough to fit the ~330 tables (as of this writing) in
+        # Django's test suite.
+        return lru_cache(maxsize=512)(self.__foreign_key_constraints)
+
+    def sql_flush(self, style, tables, *, reset_sequences=False, allow_cascade=False):
+        if not tables:
+            return []
+
+        truncated_tables = {table.upper() for table in tables}
+        constraints = set()
+        # Oracle's TRUNCATE CASCADE only works with ON DELETE CASCADE foreign
+        # keys which Django doesn't define. Emulate the PostgreSQL behavior
+        # which truncates all dependent tables by manually retrieving all
+        # foreign key constraints and resolving dependencies.
+        for table in tables:
+            for foreign_table, constraint in self._foreign_key_constraints(
+                table, recursive=allow_cascade
+            ):
+                if allow_cascade:
+                    truncated_tables.add(foreign_table)
+                constraints.add((foreign_table, constraint))
+        sql = (
+            [
+                "%s %s %s %s %s %s %s %s;"
+                % (
+                    style.SQL_KEYWORD("ALTER"),
+                    style.SQL_KEYWORD("TABLE"),
+                    style.SQL_FIELD(self.quote_name(table)),
+                    style.SQL_KEYWORD("DISABLE"),
+                    style.SQL_KEYWORD("CONSTRAINT"),
+                    style.SQL_FIELD(self.quote_name(constraint)),
+                    style.SQL_KEYWORD("KEEP"),
+                    style.SQL_KEYWORD("INDEX"),
+                )
+                for table, constraint in constraints
+            ]
+            + [
+                "%s %s %s;"
+                % (
+                    style.SQL_KEYWORD("TRUNCATE"),
+                    style.SQL_KEYWORD("TABLE"),
+                    style.SQL_FIELD(self.quote_name(table)),
+                )
+                for table in truncated_tables
+            ]
+            + [
+                "%s %s %s %s %s %s;"
+                % (
+                    style.SQL_KEYWORD("ALTER"),
+                    style.SQL_KEYWORD("TABLE"),
+                    style.SQL_FIELD(self.quote_name(table)),
+                    style.SQL_KEYWORD("ENABLE"),
+                    style.SQL_KEYWORD("CONSTRAINT"),
+                    style.SQL_FIELD(self.quote_name(constraint)),
+                )
+                for table, constraint in constraints
+            ]
+        )
+        if reset_sequences:
+            sequences = [
+                sequence
+                for sequence in self.connection.introspection.sequence_list()
+                if sequence["table"].upper() in truncated_tables
+            ]
+            # Since we've just deleted all the rows, running our sequence ALTER
+            # code will reset the sequence to 0.
+            sql.extend(self.sequence_reset_by_name_sql(style, sequences))
+        return sql
+
+    def sequence_reset_by_name_sql(self, style, sequences):
+        sql = []
+        for sequence_info in sequences:
+            no_autofield_sequence_name = self._get_no_autofield_sequence_name(
+                sequence_info["table"]
+            )
+            table = self.quote_name(sequence_info["table"])
+            column = self.quote_name(sequence_info["column"] or "id")
+            query = self._sequence_reset_sql % {
+                "no_autofield_sequence_name": no_autofield_sequence_name,
+                "table": table,
+                "column": column,
+                "table_name": strip_quotes(table),
+                "column_name": strip_quotes(column),
+            }
+            sql.append(query)
+        return sql
+
+    def sequence_reset_sql(self, style, model_list):
+        output = []
+        query = self._sequence_reset_sql
+        for model in model_list:
+            for f in model._meta.local_fields:
+                if isinstance(f, AutoField):
+                    no_autofield_sequence_name = self._get_no_autofield_sequence_name(
+                        model._meta.db_table
+                    )
+                    table = self.quote_name(model._meta.db_table)
+                    column = self.quote_name(f.column)
+                    output.append(
+                        query
+                        % {
+                            "no_autofield_sequence_name": no_autofield_sequence_name,
+                            "table": table,
+                            "column": column,
+                            "table_name": strip_quotes(table),
+                            "column_name": strip_quotes(column),
+                        }
+                    )
+                    # Only one AutoField is allowed per model, so don't
+                    # continue to loop
+                    break
+        return output
+
+    def start_transaction_sql(self):
+        return ""
+
+    def tablespace_sql(self, tablespace, inline=False):
+        if inline:
+            return "USING INDEX TABLESPACE %s" % self.quote_name(tablespace)
+        else:
+            return "TABLESPACE %s" % self.quote_name(tablespace)
+
+    def adapt_datefield_value(self, value):
+        """
+        Transform a date value to an object compatible with what is expected
+        by the backend driver for date columns.
+        The default implementation transforms the date to text, but that is not
+        necessary for Oracle.
+        """
+        return value
+
+    def adapt_datetimefield_value(self, value):
+        """
+        Transform a datetime value to an object compatible with what is expected
+        by the backend driver for datetime columns.
+
+        If naive datetime is passed assumes that is in UTC. Normally Django
+        models.DateTimeField makes sure that if USE_TZ is True passed datetime
+        is timezone aware.
+        """
+
+        if value is None:
+            return None
+
+        # Expression values are adapted by the database.
+        if hasattr(value, "resolve_expression"):
+            return value
+
+        # cx_Oracle doesn't support tz-aware datetimes
+        if timezone.is_aware(value):
+            if settings.USE_TZ:
+                value = timezone.make_naive(value, self.connection.timezone)
+            else:
+                raise ValueError(
+                    "Oracle backend does not support timezone-aware datetimes when "
+                    "USE_TZ is False."
+                )
+
+        return Oracle_datetime.from_datetime(value)
+
+    def adapt_timefield_value(self, value):
+        if value is None:
+            return None
+
+        # Expression values are adapted by the database.
+        if hasattr(value, "resolve_expression"):
+            return value
+
+        if isinstance(value, str):
+            return datetime.datetime.strptime(value, "%H:%M:%S")
+
+        # Oracle doesn't support tz-aware times
+        if timezone.is_aware(value):
+            raise ValueError("Oracle backend does not support timezone-aware times.")
+
+        return Oracle_datetime(
+            1900, 1, 1, value.hour, value.minute, value.second, value.microsecond
+        )
+
+    def adapt_decimalfield_value(self, value, max_digits=None, decimal_places=None):
+        return value
+
+    def combine_expression(self, connector, sub_expressions):
+        lhs, rhs = sub_expressions
+        if connector == "%%":
+            return "MOD(%s)" % ",".join(sub_expressions)
+        elif connector == "&":
+            return "BITAND(%s)" % ",".join(sub_expressions)
+        elif connector == "|":
+            return "BITAND(-%(lhs)s-1,%(rhs)s)+%(lhs)s" % {"lhs": lhs, "rhs": rhs}
+        elif connector == "<<":
+            return "(%(lhs)s * POWER(2, %(rhs)s))" % {"lhs": lhs, "rhs": rhs}
+        elif connector == ">>":
+            return "FLOOR(%(lhs)s / POWER(2, %(rhs)s))" % {"lhs": lhs, "rhs": rhs}
+        elif connector == "^":
+            return "POWER(%s)" % ",".join(sub_expressions)
+        elif connector == "#":
+            raise NotSupportedError("Bitwise XOR is not supported in Oracle.")
+        return super().combine_expression(connector, sub_expressions)
+
+    def _get_no_autofield_sequence_name(self, table):
+        """
+        Manually created sequence name to keep backward compatibility for
+        AutoFields that aren't Oracle identity columns.
+        """
+        name_length = self.max_name_length() - 3
+        return "%s_SQ" % truncate_name(strip_quotes(table), name_length).upper()
+
+    def _get_sequence_name(self, cursor, table, pk_name):
+        cursor.execute(
+            """
+            SELECT sequence_name
+            FROM user_tab_identity_cols
+            WHERE table_name = UPPER(%s)
+            AND column_name = UPPER(%s)""",
+            [table, pk_name],
+        )
+        row = cursor.fetchone()
+        return self._get_no_autofield_sequence_name(table) if row is None else row[0]
+
+    def bulk_insert_sql(self, fields, placeholder_rows):
+        query = []
+        for row in placeholder_rows:
+            select = []
+            for i, placeholder in enumerate(row):
+                # A model without any fields has fields=[None].
+                if fields[i]:
+                    internal_type = getattr(
+                        fields[i], "target_field", fields[i]
+                    ).get_internal_type()
+                    placeholder = (
+                        BulkInsertMapper.types.get(internal_type, "%s") % placeholder
+                    )
+                # Add columns aliases to the first select to avoid "ORA-00918:
+                # column ambiguously defined" when two or more columns in the
+                # first select have the same value.
+                if not query:
+                    placeholder = "%s col_%s" % (placeholder, i)
+                select.append(placeholder)
+            query.append("SELECT %s FROM DUAL" % ", ".join(select))
+        # Bulk insert to tables with Oracle identity columns causes Oracle to
+        # add sequence.nextval to it. Sequence.nextval cannot be used with the
+        # UNION operator. To prevent incorrect SQL, move UNION to a subquery.
+        return "SELECT * FROM (%s)" % " UNION ALL ".join(query)
+
+    def subtract_temporals(self, internal_type, lhs, rhs):
+        if internal_type == "DateField":
+            lhs_sql, lhs_params = lhs
+            rhs_sql, rhs_params = rhs
+            params = (*lhs_params, *rhs_params)
+            return (
+                "NUMTODSINTERVAL(TO_NUMBER(%s - %s), 'DAY')" % (lhs_sql, rhs_sql),
+                params,
+            )
+        return super().subtract_temporals(internal_type, lhs, rhs)
+
+    def bulk_batch_size(self, fields, objs):
+        """Oracle restricts the number of parameters in a query."""
+        if fields:
+            return self.connection.features.max_query_params // len(fields)
+        return len(objs)
+
+    def conditional_expression_supported_in_where_clause(self, expression):
+        """
+        Oracle supports only EXISTS(...) or filters in the WHERE clause, others
+        must be compared with True.
+        """
+        if isinstance(expression, (Exists, Lookup, WhereNode)):
+            return True
+        if isinstance(expression, ExpressionWrapper) and expression.conditional:
+            return self.conditional_expression_supported_in_where_clause(
+                expression.expression
+            )
+        if isinstance(expression, RawSQL) and expression.conditional:
+            return True
+        return False
diff --git a/tests/corpus/.refactoring-benchmark/expected.special.py b/tests/corpus/.refactoring-benchmark/expected.special.py
new file mode 100644
index 0000000..94a6ec7
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/expected.special.py
@@ -0,0 +1,208 @@
+from django.db import router
+
+from .base import Operation
+
+
+class SeparateDatabaseAndState(Operation):
+    """
+    Take two lists of operations - ones that will be used for the database,
+    and ones that will be used for the state change. This allows operations
+    that don't support state change to have it applied, or have operations
+    that affect the state or not the database, or so on.
+    """
+
+    serialization_expand_args = ["database_operations", "state_operations"]
+
+    def __init__(self, database_operations=None, state_operations=None):
+        self.database_operations = database_operations or []
+        self.state_operations = state_operations or []
+
+    def deconstruct(self):
+        kwargs = {}
+        if self.database_operations:
+            kwargs["database_operations"] = self.database_operations
+        if self.state_operations:
+            kwargs["state_operations"] = self.state_operations
+        return (self.__class__.__qualname__, [], kwargs)
+
+    def state_forwards(self, app_label, state):
+        for state_operation in self.state_operations:
+            state_operation.state_forwards(app_label, state)
+
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
+        # We calculate state separately in here since our state functions aren't useful
+        for database_operation in self.database_operations:
+            to_state = from_state.clone()
+            database_operation.state_forwards(app_label, to_state)
+            database_operation.database_forwards(
+                app_label, schema_editor, from_state, to_state
+            )
+            from_state = to_state
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        # We calculate state separately in here since our state functions aren't useful
+        to_states = {}
+        for dbop in self.database_operations:
+            to_states[dbop] = to_state
+            to_state = to_state.clone()
+            dbop.state_forwards(app_label, to_state)
+        # to_state now has the states of all the database_operations applied
+        # which is the from_state for the backwards migration of the last
+        # operation.
+        for database_operation in reversed(self.database_operations):
+            from_state = to_state
+            to_state = to_states[database_operation]
+            database_operation.database_backwards(
+                app_label, schema_editor, from_state, to_state
+            )
+
+    def describe(self):
+        return "Custom state/database change combination"
+
+
+class RunSQL(Operation):
+    """
+    Run some raw SQL. A reverse SQL statement may be provided.
+
+    Also accept a list of operations that represent the state change effected
+    by this SQL change, in case it's custom column/table creation/deletion.
+    """
+
+    noop = ""
+
+    def __init__(
+        self, sql, reverse_sql=None, state_operations=None, hints=None, elidable=False
+    ):
+        self.sql = sql
+        self.reverse_sql = reverse_sql
+        self.state_operations = state_operations or []
+        self.hints = hints or {}
+        self.elidable = elidable
+
+    def deconstruct(self):
+        kwargs = {
+            "sql": self.sql,
+        }
+        if self.reverse_sql is not None:
+            kwargs["reverse_sql"] = self.reverse_sql
+        if self.state_operations:
+            kwargs["state_operations"] = self.state_operations
+        if self.hints:
+            kwargs["hints"] = self.hints
+        return (self.__class__.__qualname__, [], kwargs)
+
+    @property
+    def reversible(self):
+        return self.reverse_sql is not None
+
+    def state_forwards(self, app_label, state):
+        for state_operation in self.state_operations:
+            state_operation.state_forwards(app_label, state)
+
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
+        if router.allow_migrate(
+            schema_editor.connection.alias, app_label, **self.hints
+        ):
+            self._run_sql(schema_editor, self.sql)
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        if self.reverse_sql is None:
+            raise NotImplementedError("You cannot reverse this operation")
+        if router.allow_migrate(
+            schema_editor.connection.alias, app_label, **self.hints
+        ):
+            self._run_sql(schema_editor, self.reverse_sql)
+
+    def describe(self):
+        return "Raw SQL operation"
+
+    def _run_sql(self, schema_editor, sqls):
+        if isinstance(sqls, (list, tuple)):
+            for sql in sqls:
+                params = None
+                if isinstance(sql, (list, tuple)):
+                    elements = len(sql)
+                    if elements == 2:
+                        sql, params = sql
+                    else:
+                        raise ValueError("Expected a 2-tuple but got %d" % elements)
+                schema_editor.execute(sql, params=params)
+        elif sqls != RunSQL.noop:
+            statements = schema_editor.connection.ops.prepare_sql_script(sqls)
+            for statement in statements:
+                schema_editor.execute(statement, params=None)
+
+
+class RunPython(Operation):
+    """
+    Run Python code in a context suitable for doing versioned ORM operations.
+    """
+
+    reduces_to_sql = False
+
+    def __init__(
+        self, code, reverse_code=None, atomic=None, hints=None, elidable=False
+    ):
+        self.atomic = atomic
+        # Forwards code
+        if not callable(code):
+            raise ValueError("RunPython must be supplied with a callable")
+        self.code = code
+        # Reverse code
+        if reverse_code is None:
+            self.reverse_code = None
+        else:
+            if not callable(reverse_code):
+                raise ValueError("RunPython must be supplied with callable arguments")
+            self.reverse_code = reverse_code
+        self.hints = hints or {}
+        self.elidable = elidable
+
+    def deconstruct(self):
+        kwargs = {
+            "code": self.code,
+        }
+        if self.reverse_code is not None:
+            kwargs["reverse_code"] = self.reverse_code
+        if self.atomic is not None:
+            kwargs["atomic"] = self.atomic
+        if self.hints:
+            kwargs["hints"] = self.hints
+        return (self.__class__.__qualname__, [], kwargs)
+
+    @property
+    def reversible(self):
+        return self.reverse_code is not None
+
+    def state_forwards(self, app_label, state):
+        # RunPython objects have no state effect. To add some, combine this
+        # with SeparateDatabaseAndState.
+        pass
+
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
+        # RunPython has access to all models. Ensure that all models are
+        # reloaded in case any are delayed.
+        from_state.clear_delayed_apps_cache()
+        if router.allow_migrate(
+            schema_editor.connection.alias, app_label, **self.hints
+        ):
+            # We now execute the Python code in a context that contains a 'models'
+            # object, representing the versioned models as an app registry.
+            # We could try to override the global cache, but then people will still
+            # use direct imports, so we go with a documentation approach instead.
+            self.code(from_state.apps, schema_editor)
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        if self.reverse_code is None:
+            raise NotImplementedError("You cannot reverse this operation")
+        if router.allow_migrate(
+            schema_editor.connection.alias, app_label, **self.hints
+        ):
+            self.reverse_code(from_state.apps, schema_editor)
+
+    def describe(self):
+        return "Raw Python operation"
+
+    @staticmethod
+    def noop(apps, schema_editor):
+        return None
diff --git a/tests/corpus/.refactoring-benchmark/generic_bsd.py b/tests/corpus/.refactoring-benchmark/generic_bsd.py
new file mode 100644
index 0000000..fc0f5d6
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/generic_bsd.py
@@ -0,0 +1,320 @@
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
+
+from __future__ import annotations
+
+import re
+import socket
+import struct
+
+from ansible.module_utils.facts.network.base import Network
+
+
+def parse_inet_line( words, current_if, ips):
+    # netbsd show aliases like this
+    #  lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 33184
+    #         inet 127.0.0.1 netmask 0xff000000
+    #         inet alias 127.1.1.1 netmask 0xff000000
+    if words[1] == 'alias':
+        del words[1]
+
+    address = {'address': words[1]}
+    # cidr style ip address (eg, 127.0.0.1/24) in inet line
+    # used in netbsd ifconfig -e output after 7.1
+    if '/' in address['address']:
+        ip_address, cidr_mask = address['address'].split('/')
+
+        address['address'] = ip_address
+
+        netmask_length = int(cidr_mask)
+        netmask_bin = (1 << 32) - (1 << 32 >> int(netmask_length))
+        address['netmask'] = socket.inet_ntoa(struct.pack('!L', netmask_bin))
+
+        if len(words) > 5:
+            address['broadcast'] = words[3]
+
+    else:
+        # Don't just assume columns, use "netmask" as the index for the prior column
+        try:
+            netmask_idx = words.index('netmask') + 1
+        except ValueError:
+            netmask_idx = 3
+
+        # deal with hex netmask
+        if re.match('([0-9a-f]){8}$', words[netmask_idx]):
+            netmask = '0x' + words[netmask_idx]
+        else:
+            netmask = words[netmask_idx]
+
+        if netmask.startswith('0x'):
+            address['netmask'] = socket.inet_ntoa(struct.pack('!L', int(netmask, base=16)))
+        else:
+            # otherwise assume this is a dotted quad
+            address['netmask'] = netmask
+    # calculate the network
+    address_bin = struct.unpack('!L', socket.inet_aton(address['address']))[0]
+    netmask_bin = struct.unpack('!L', socket.inet_aton(address['netmask']))[0]
+    address['network'] = socket.inet_ntoa(struct.pack('!L', address_bin & netmask_bin))
+    if 'broadcast' not in address:
+        # broadcast may be given or we need to calculate
+        try:
+            broadcast_idx = words.index('broadcast') + 1
+        except ValueError:
+            address['broadcast'] = socket.inet_ntoa(struct.pack('!L', address_bin | (~netmask_bin & 0xffffffff)))
+        else:
+            address['broadcast'] = words[broadcast_idx]
+
+    # add to our list of addresses
+    if not words[1].startswith('127.'):
+        ips['all_ipv4_addresses'].append(address['address'])
+    current_if['ipv4'].append(address)
+class GenericBsdIfconfigNetwork(Network):
+    """
+    This is a generic BSD subclass of Network using the ifconfig command.
+    It defines
+    - interfaces (a list of interface names)
+    - interface_<name> dictionary of ipv4, ipv6, and mac address information.
+    - all_ipv4_addresses and all_ipv6_addresses: lists of all configured addresses.
+    """
+    platform = 'Generic_BSD_Ifconfig'
+
+    def populate(self, collected_facts=None):
+        network_facts = {}
+        ifconfig_path = self.module.get_bin_path('ifconfig')
+
+        if ifconfig_path is None:
+            return network_facts
+
+        route_path = self.module.get_bin_path('route')
+
+        if route_path is None:
+            return network_facts
+
+        default_ipv4, default_ipv6 = self.get_default_interfaces(route_path)
+        interfaces, ips = self.get_interfaces_info(ifconfig_path)
+        interfaces = self.detect_type_media(interfaces)
+
+        self.merge_default_interface(default_ipv4, interfaces, 'ipv4')
+        self.merge_default_interface(default_ipv6, interfaces, 'ipv6')
+        network_facts['interfaces'] = sorted(list(interfaces.keys()))
+
+        for iface in interfaces:
+            network_facts[iface] = interfaces[iface]
+
+        network_facts['default_ipv4'] = default_ipv4
+        network_facts['default_ipv6'] = default_ipv6
+        network_facts['all_ipv4_addresses'] = ips['all_ipv4_addresses']
+        network_facts['all_ipv6_addresses'] = ips['all_ipv6_addresses']
+
+        return network_facts
+
+    def detect_type_media(self, interfaces):
+        for iface in interfaces:
+            if 'media' in interfaces[iface]:
+                if 'ether' in interfaces[iface]['media'].lower():
+                    interfaces[iface]['type'] = 'ether'
+        return interfaces
+
+    def get_default_interfaces(self, route_path):
+
+        # Use the commands:
+        #     route -n get default
+        #     route -n get -inet6 default
+        # to find out the default outgoing interface, address, and gateway
+
+        command = dict(v4=[route_path, '-n', 'get', 'default'],
+                       v6=[route_path, '-n', 'get', '-inet6', 'default'])
+
+        interface = dict(v4={}, v6={})
+
+        for v in 'v4', 'v6':
+
+            if v == 'v6' and not socket.has_ipv6:
+                continue
+            rc, out, err = self.module.run_command(command[v])
+            if not out:
+                # v6 routing may result in
+                #   RTNETLINK answers: Invalid argument
+                continue
+            for line in out.splitlines():
+                words = line.strip().split(': ')
+                # Collect output from route command
+                if len(words) > 1:
+                    if words[0] == 'interface':
+                        interface[v]['interface'] = words[1]
+                    if words[0] == 'gateway':
+                        interface[v]['gateway'] = words[1]
+                    # help pick the right interface address on OpenBSD
+                    if words[0] == 'if address':
+                        interface[v]['address'] = words[1]
+                    # help pick the right interface address on NetBSD
+                    if words[0] == 'local addr':
+                        interface[v]['address'] = words[1]
+
+        return interface['v4'], interface['v6']
+
+    def get_interfaces_info(self, ifconfig_path, ifconfig_options='-a'):
+        interfaces = {}
+        current_if = {}
+        ips = dict(
+            all_ipv4_addresses=[],
+            all_ipv6_addresses=[],
+        )
+        # FreeBSD, DragonflyBSD, NetBSD, OpenBSD and macOS all implicitly add '-a'
+        # when running the command 'ifconfig'.
+        # Solaris must explicitly run the command 'ifconfig -a'.
+        rc, out, err = self.module.run_command([ifconfig_path, ifconfig_options])
+
+        for line in out.splitlines():
+
+            if line:
+                words = line.split()
+
+                if words[0] == 'pass':
+                    continue
+                elif re.match(r'^\S', line) and len(words) > 3:
+                    current_if = self.parse_interface_line(words)
+                    interfaces[current_if['device']] = current_if
+                elif words[0].startswith('options='):
+                    self.parse_options_line(words, current_if, ips)
+                elif words[0] == 'nd6':
+                    self.parse_nd6_line(words, current_if, ips)
+                elif words[0] == 'ether':
+                    self.parse_ether_line(words, current_if, ips)
+                elif words[0] == 'media:':
+                    self.parse_media_line(words, current_if, ips)
+                elif words[0] == 'status:':
+                    self.parse_status_line(words, current_if, ips)
+                elif words[0] == 'lladdr':
+                    self.parse_lladdr_line(words, current_if, ips)
+                elif words[0] == 'inet':
+                    parse_inet_line(words, current_if, ips)
+                elif words[0] == 'inet6':
+                    self.parse_inet6_line(words, current_if, ips)
+                elif words[0] == 'tunnel':
+                    self.parse_tunnel_line(words, current_if, ips)
+                else:
+                    self.parse_unknown_line(words, current_if, ips)
+
+        return interfaces, ips
+
+    def parse_interface_line(self, words):
+        device = words[0][0:-1]
+        current_if = {'device': device, 'ipv4': [], 'ipv6': [], 'type': 'unknown'}
+        current_if['flags'] = self.get_options(words[1])
+        if 'LOOPBACK' in current_if['flags']:
+            current_if['type'] = 'loopback'
+        current_if['macaddress'] = 'unknown'    # will be overwritten later
+
+        if len(words) >= 5:  # Newer FreeBSD versions
+            current_if['metric'] = words[3]
+            current_if['mtu'] = words[5]
+        else:
+            current_if['mtu'] = words[3]
+
+        return current_if
+
+    def parse_options_line(self, words, current_if, ips):
+        # Mac has options like this...
+        current_if['options'] = self.get_options(words[0])
+
+    def parse_nd6_line(self, words, current_if, ips):
+        # FreeBSD has options like this...
+        current_if['options'] = self.get_options(words[1])
+
+    def parse_ether_line(self, words, current_if, ips):
+        current_if['macaddress'] = words[1]
+        current_if['type'] = 'ether'
+
+    def parse_media_line(self, words, current_if, ips):
+        # not sure if this is useful - we also drop information
+        current_if['media'] = words[1]
+        if len(words) > 2:
+            current_if['media_select'] = words[2]
+        if len(words) > 3:
+            current_if['media_type'] = words[3][1:]
+        if len(words) > 4:
+            current_if['media_options'] = self.get_options(words[4])
+
+    def parse_status_line(self, words, current_if, ips):
+        current_if['status'] = words[1]
+
+    def parse_lladdr_line(self, words, current_if, ips):
+        current_if['lladdr'] = words[1]
+
+
+    def parse_inet6_line(self, words, current_if, ips):
+        address = {'address': words[1]}
+
+        # using cidr style addresses, ala NetBSD ifconfig post 7.1
+        if '/' in address['address']:
+            ip_address, cidr_mask = address['address'].split('/')
+
+            address['address'] = ip_address
+            address['prefix'] = cidr_mask
+
+            if len(words) > 5:
+                address['scope'] = words[5]
+        else:
+            if (len(words) >= 4) and (words[2] == 'prefixlen'):
+                address['prefix'] = words[3]
+            if (len(words) >= 6) and (words[4] == 'scopeid'):
+                address['scope'] = words[5]
+
+        localhost6 = ['::1', '::1/128', 'fe80::1%lo0']
+        if address['address'] not in localhost6:
+            ips['all_ipv6_addresses'].append(address['address'])
+        current_if['ipv6'].append(address)
+
+    def parse_tunnel_line(self, words, current_if, ips):
+        current_if['type'] = 'tunnel'
+
+    def parse_unknown_line(self, words, current_if, ips):
+        # we are going to ignore unknown lines here - this may be
+        # a bad idea - but you can override it in your subclass
+        pass
+
+    # TODO: these are module scope static function candidates
+    #       (most of the class is really...)
+    def get_options(self, option_string):
+        start = option_string.find('<') + 1
+        end = option_string.rfind('>')
+        if (start > 0) and (end > 0) and (end > start + 1):
+            option_csv = option_string[start:end]
+            return option_csv.split(',')
+        else:
+            return []
+
+    def merge_default_interface(self, defaults, interfaces, ip_type):
+        if 'interface' not in defaults:
+            return
+        if not defaults['interface'] in interfaces:
+            return
+        ifinfo = interfaces[defaults['interface']]
+        # copy all the interface values across except addresses
+        for item in ifinfo:
+            if item != 'ipv4' and item != 'ipv6':
+                defaults[item] = ifinfo[item]
+
+        ipinfo = []
+        if 'address' in defaults:
+            ipinfo = [x for x in ifinfo[ip_type] if x['address'] == defaults['address']]
+
+        if len(ipinfo) == 0:
+            ipinfo = ifinfo[ip_type]
+
+        if len(ipinfo) > 0:
+            for item in ipinfo[0]:
+                defaults[item] = ipinfo[0][item]
diff --git a/tests/corpus/.refactoring-benchmark/graph_drawer.py b/tests/corpus/.refactoring-benchmark/graph_drawer.py
new file mode 100644
index 0000000..2e41811
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/graph_drawer.py
@@ -0,0 +1,418 @@
+
+import hashlib
+import torch
+import torch.fx
+from typing import Any, Dict, Optional, TYPE_CHECKING
+from torch.fx.node import _get_qualified_name, _format_arg
+from torch.fx.graph import _parse_stack_trace
+from torch.fx.passes.shape_prop import TensorMetadata
+from torch.fx._compatibility import compatibility
+from itertools import chain
+
+__all__ = ['FxGraphDrawer']
+try:
+    import pydot
+    HAS_PYDOT = True
+except ImportError:
+    HAS_PYDOT = False
+
+_COLOR_MAP = {
+    "placeholder": '"AliceBlue"',
+    "call_module": "LemonChiffon1",
+    "get_param": "Yellow2",
+    "get_attr": "LightGrey",
+    "output": "PowderBlue",
+}
+
+_HASH_COLOR_MAP = [
+    "CadetBlue1",
+    "Coral",
+    "DarkOliveGreen1",
+    "DarkSeaGreen1",
+    "GhostWhite",
+    "Khaki1",
+    "LavenderBlush1",
+    "LightSkyBlue",
+    "MistyRose1",
+    "MistyRose2",
+    "PaleTurquoise2",
+    "PeachPuff1",
+    "Salmon",
+    "Thistle1",
+    "Thistle3",
+    "Wheat1",
+]
+
+_WEIGHT_TEMPLATE = {
+    "fillcolor": "Salmon",
+    "style": '"filled,rounded"',
+    "fontcolor": "#000000",
+}
+
+if HAS_PYDOT:
+    @compatibility(is_backward_compatible=False)
+    class FxGraphDrawer:
+        """
+        Visualize a torch.fx.Graph with graphviz
+        Basic usage:
+            g = FxGraphDrawer(symbolic_traced, "resnet18")
+            g.get_dot_graph().write_svg("a.svg")
+        """
+
+        def __init__(
+            self,
+            graph_module: torch.fx.GraphModule,
+            name: str,
+            ignore_getattr: bool = False,
+            ignore_parameters_and_buffers: bool = False,
+            skip_node_names_in_args: bool = True,
+            parse_stack_trace: bool = False,
+            dot_graph_shape: Optional[str] = None,
+        ):
+            self._name = name
+            self.dot_graph_shape = (
+                dot_graph_shape if dot_graph_shape is not None else "record"
+            )
+            _WEIGHT_TEMPLATE["shape"] = self.dot_graph_shape
+
+            self._dot_graphs = {
+                name: self._to_dot(
+                    graph_module, name, ignore_getattr, ignore_parameters_and_buffers, skip_node_names_in_args, parse_stack_trace
+                )
+            }
+
+            for node in graph_module.graph.nodes:
+                if node.op != "call_module":
+                    continue
+
+                leaf_node = self._get_leaf_node(graph_module, node)
+
+                if not isinstance(leaf_node, torch.fx.GraphModule):
+                    continue
+
+
+                self._dot_graphs[f"{name}_{node.target}"] = self._to_dot(
+                    leaf_node,
+                    f"{name}_{node.target}",
+                    ignore_getattr,
+                    ignore_parameters_and_buffers,
+                    skip_node_names_in_args,
+                    parse_stack_trace,
+                )
+
+        def get_dot_graph(self, submod_name=None) -> pydot.Dot:
+            """
+            Visualize a torch.fx.Graph with graphviz
+            Example:
+                >>> # xdoctest: +REQUIRES(module:pydot)
+                >>> # define module
+                >>> class MyModule(torch.nn.Module):
+                >>>     def __init__(self):
+                >>>         super().__init__()
+                >>>         self.linear = torch.nn.Linear(4, 5)
+                >>>     def forward(self, x):
+                >>>         return self.linear(x).clamp(min=0.0, max=1.0)
+                >>> module = MyModule()
+                >>> # trace the module
+                >>> symbolic_traced = torch.fx.symbolic_trace(module)
+                >>> # setup output file
+                >>> import ubelt as ub
+                >>> dpath = ub.Path.appdir('torch/tests/FxGraphDrawer').ensuredir()
+                >>> fpath = dpath / 'linear.svg'
+                >>> # draw the graph
+                >>> g = FxGraphDrawer(symbolic_traced, "linear")
+                >>> g.get_dot_graph().write_svg(fpath)
+            """
+            if submod_name is None:
+                return self.get_main_dot_graph()
+            else:
+                return self.get_submod_dot_graph(submod_name)
+
+        def get_main_dot_graph(self) -> pydot.Dot:
+            return self._dot_graphs[self._name]
+
+        def get_submod_dot_graph(self, submod_name) -> pydot.Dot:
+            return self._dot_graphs[f"{self._name}_{submod_name}"]
+
+        def get_all_dot_graphs(self) -> Dict[str, pydot.Dot]:
+            return self._dot_graphs
+
+        def _get_node_style(self, node: torch.fx.Node) -> Dict[str, str]:
+
+            template = {
+                "shape": self.dot_graph_shape,
+                "fillcolor": "#CAFFE3",
+                "style": '"filled,rounded"',
+                "fontcolor": "#000000",
+            }
+            if node.op in _COLOR_MAP:
+                template["fillcolor"] = _COLOR_MAP[node.op]
+            else:
+                # Use a random color for each node; based on its name so it's stable.
+                target_name = node._pretty_print_target(node.target)
+                target_hash = int(hashlib.md5(target_name.encode()).hexdigest()[:8], 16)
+                template["fillcolor"] = _HASH_COLOR_MAP[target_hash % len(_HASH_COLOR_MAP)]
+            return template
+
+        def _get_leaf_node(
+            self, module: torch.nn.Module, node: torch.fx.Node
+        ) -> torch.nn.Module:
+            py_obj = module
+            assert isinstance(node.target, str)
+            atoms = node.target.split(".")
+            for atom in atoms:
+                if not hasattr(py_obj, atom):
+                    raise RuntimeError(
+                        str(py_obj) + " does not have attribute " + atom + "!"
+                    )
+                py_obj = getattr(py_obj, atom)
+            return py_obj
+
+        def _typename(self, target: Any) -> str:
+            if isinstance(target, torch.nn.Module):
+                ret = torch.typename(target)
+            elif isinstance(target, str):
+                ret = target
+            else:
+                ret = _get_qualified_name(target)
+
+            # Escape "{" and "}" to prevent dot files like:
+            # https://gist.github.com/SungMinCho/1a017aab662c75d805c5954d62c5aabc
+            # which triggers `Error: bad label format (...)` from dot
+            return ret.replace("{", r"\{").replace("}", r"\}")
+
+        # shorten path to avoid drawing long boxes
+        # for full path = '/home/weif/pytorch/test.py'
+        # return short path = 'pytorch/test.py'
+        def _shorten_file_name(
+            self,
+            full_file_name: str,
+            truncate_to_last_n: int = 2,
+        ):
+            splits = full_file_name.split('/')
+            if len(splits) >= truncate_to_last_n:
+                return '/'.join(splits[-truncate_to_last_n:])
+            return full_file_name
+
+
+        def _get_node_label(
+            self,
+            module: torch.fx.GraphModule,
+            node: torch.fx.Node,
+            skip_node_names_in_args: bool,
+            parse_stack_trace: bool,
+        ) -> str:
+            def _get_str_for_args_kwargs(arg):
+                if isinstance(arg, tuple):
+                    prefix, suffix = r"|args=(\l", r",\n)\l"
+                    arg_strs_list = [_format_arg(a, max_list_len=8) for a in arg]
+                elif isinstance(arg, dict):
+                    prefix, suffix = r"|kwargs={\l", r",\n}\l"
+                    arg_strs_list = [
+                        f"{k}: {_format_arg(v, max_list_len=8)}"
+                        for k, v in arg.items()
+                    ]
+                else:  # Fall back to nothing in unexpected case.
+                    return ""
+
+                # Strip out node names if requested.
+                if skip_node_names_in_args:
+                    arg_strs_list = [a for a in arg_strs_list if "%" not in a]
+                if len(arg_strs_list) == 0:
+                    return ""
+                arg_strs = prefix + r",\n".join(arg_strs_list) + suffix
+                if len(arg_strs_list) == 1:
+                    arg_strs = arg_strs.replace(r"\l", "").replace(r"\n", "")
+                return arg_strs.replace("{", r"\{").replace("}", r"\}")
+
+
+            label = "{" + f"name=%{node.name}|op_code={node.op}\n"
+
+            if node.op == "call_module":
+                leaf_module = self._get_leaf_node(module, node)
+                label += r"\n" + self._typename(leaf_module) + r"\n|"
+                extra = ""
+                if hasattr(leaf_module, "__constants__"):
+                    extra = r"\n".join(
+                        [f"{c}: {getattr(leaf_module, c)}" for c in leaf_module.__constants__]  # type: ignore[union-attr]
+                    )
+                label += extra + r"\n"
+            else:
+                label += f"|target={self._typename(node.target)}" + r"\n"
+                if len(node.args) > 0:
+                    label += _get_str_for_args_kwargs(node.args)
+                if len(node.kwargs) > 0:
+                    label += _get_str_for_args_kwargs(node.kwargs)
+                label += f"|num_users={len(node.users)}" + r"\n"
+
+            tensor_meta = node.meta.get('tensor_meta')
+            label += self._tensor_meta_to_label(tensor_meta)
+
+            # for original fx graph
+            # print buf=buf0, n_origin=6
+            buf_meta = node.meta.get('buf_meta', None)
+            if buf_meta is not None:
+                label += f"|buf={buf_meta.name}" + r"\n"
+                label += f"|n_origin={buf_meta.n_origin}" + r"\n"
+
+            # for original fx graph
+            # print file:lineno code
+            if parse_stack_trace and node.stack_trace is not None:
+                parsed_stack_trace = _parse_stack_trace(node.stack_trace)
+                fname = self._shorten_file_name(parsed_stack_trace.file)
+                label += f"|file={fname}:{parsed_stack_trace.lineno} {parsed_stack_trace.code}" + r"\n"
+
+
+            return label + "}"
+
+        def _tensor_meta_to_label(self, tm) -> str:
+            if tm is None:
+                return ""
+            elif isinstance(tm, TensorMetadata):
+                return self._stringify_tensor_meta(tm)
+            elif isinstance(tm, list):
+                result = ""
+                for item in tm:
+                    result += self._tensor_meta_to_label(item)
+                return result
+            elif isinstance(tm, dict):
+                result = ""
+                for v in tm.values():
+                    result += self._tensor_meta_to_label(v)
+                return result
+            elif isinstance(tm, tuple):
+                result = ""
+                for item in tm:
+                    result += self._tensor_meta_to_label(item)
+                return result
+            else:
+                raise RuntimeError(f"Unsupported tensor meta type {type(tm)}")
+
+        def _stringify_tensor_meta(self, tm: TensorMetadata) -> str:
+            result = ""
+            if not hasattr(tm, "dtype"):
+                print("tm", tm)
+            result += "|" + "dtype" + "=" + str(tm.dtype) + r"\n"
+            result += "|" + "shape" + "=" + str(tuple(tm.shape)) + r"\n"
+            result += "|" + "requires_grad" + "=" + str(tm.requires_grad) + r"\n"
+            result += "|" + "stride" + "=" + str(tm.stride) + r"\n"
+            if tm.is_quantized:
+                assert tm.qparams is not None
+                assert "qscheme" in tm.qparams
+                qscheme = tm.qparams["qscheme"]
+                if qscheme in {
+                        torch.per_tensor_affine,
+                        torch.per_tensor_symmetric,
+                }:
+                    result += "|" + "q_scale" + "=" + str(tm.qparams["scale"]) + r"\n"
+                    result += "|" + "q_zero_point" + "=" + str(tm.qparams["zero_point"]) + r"\n"
+                elif qscheme in {
+                        torch.per_channel_affine,
+                        torch.per_channel_symmetric,
+                        torch.per_channel_affine_float_qparams,
+                }:
+                    result += "|" + "q_per_channel_scale" + "=" + str(tm.qparams["scale"]) + r"\n"
+                    result += "|" + "q_per_channel_zero_point" + "=" + str(tm.qparams["zero_point"]) + r"\n"
+                    result += "|" + "q_per_channel_axis" + "=" + str(tm.qparams["axis"]) + r"\n"
+                else:
+                    raise RuntimeError(f"Unsupported qscheme: {qscheme}")
+                result += "|" + "qscheme" + "=" + str(tm.qparams["qscheme"]) + r"\n"
+            return result
+
+        def _get_tensor_label(self, t: torch.Tensor) -> str:
+            return str(t.dtype) + str(list(t.shape)) + r"\n"
+
+        # when parse_stack_trace=True
+        # print file:lineno code
+        def _to_dot(
+            self,
+            graph_module: torch.fx.GraphModule,
+            name: str,
+            ignore_getattr: bool,
+            ignore_parameters_and_buffers: bool,
+            skip_node_names_in_args: bool,
+            parse_stack_trace: bool,
+        ) -> pydot.Dot:
+            """
+            Actual interface to visualize a fx.Graph. Note that it takes in the GraphModule instead of the Graph.
+            If ignore_parameters_and_buffers is True, the parameters and buffers
+            created with the module will not be added as nodes and edges.
+            """
+
+            # "TB" means top-to-bottom rank direction in layout
+            dot_graph = pydot.Dot(name, rankdir="TB")
+
+
+            buf_name_to_subgraph = {}
+
+            for node in graph_module.graph.nodes:
+                if ignore_getattr and node.op == "get_attr":
+                    continue
+
+                style = self._get_node_style(node)
+                dot_node = pydot.Node(
+                    node.name, label=self._get_node_label(graph_module, node, skip_node_names_in_args, parse_stack_trace), **style
+                )
+
+                current_graph = dot_graph
+
+                buf_meta = node.meta.get('buf_meta', None)
+                if buf_meta is not None and buf_meta.n_origin > 1:
+                    buf_name = buf_meta.name
+                    if buf_name not in buf_name_to_subgraph:
+                        buf_name_to_subgraph[buf_name] = pydot.Cluster(buf_name, label=buf_name)
+                    current_graph = buf_name_to_subgraph.get(buf_name)
+
+                current_graph.add_node(dot_node)
+
+                def get_module_params_or_buffers():
+                    for pname, ptensor in chain(
+                        leaf_module.named_parameters(), leaf_module.named_buffers()
+                    ):
+                        pname1 = node.name + "." + pname
+                        label1 = (
+                            pname1 + "|op_code=get_" + "parameter"
+                            if isinstance(ptensor, torch.nn.Parameter)
+                            else "buffer" + r"\l"
+                        )
+                        dot_w_node = pydot.Node(
+                            pname1,
+                            label="{" + label1 + self._get_tensor_label(ptensor) + "}",
+                            **_WEIGHT_TEMPLATE,
+                        )
+                        dot_graph.add_node(dot_w_node)
+                        dot_graph.add_edge(pydot.Edge(pname1, node.name))
+
+                if node.op == "call_module":
+                    leaf_module = self._get_leaf_node(graph_module, node)
+
+                    if not ignore_parameters_and_buffers and not isinstance(leaf_module, torch.fx.GraphModule):
+                        get_module_params_or_buffers()
+
+            for subgraph in buf_name_to_subgraph.values():
+                subgraph.set('color', 'royalblue')
+                subgraph.set('penwidth', '2')
+                dot_graph.add_subgraph(subgraph)
+
+            for node in graph_module.graph.nodes:
+                if ignore_getattr and node.op == "get_attr":
+                    continue
+
+                for user in node.users:
+                    dot_graph.add_edge(pydot.Edge(node.name, user.name))
+
+            return dot_graph
+
+else:
+    if not TYPE_CHECKING:
+        @compatibility(is_backward_compatible=False)
+        class FxGraphDrawer:
+            def __init__(
+                self,
+                graph_module: torch.fx.GraphModule,
+                name: str,
+                ignore_getattr: bool = False,
+                parse_stack_trace: bool = False,
+            ):
+                raise RuntimeError('FXGraphDrawer requires the pydot package to be installed. Please install '
+                                   'pydot through your favorite Python package manager.')
diff --git a/tests/corpus/.refactoring-benchmark/makemessages.py b/tests/corpus/.refactoring-benchmark/makemessages.py
new file mode 100644
index 0000000..1d4947f
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/makemessages.py
@@ -0,0 +1,783 @@
+import glob
+import os
+import re
+import sys
+from functools import total_ordering
+from itertools import dropwhile
+from pathlib import Path
+
+import django
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from django.core.files.temp import NamedTemporaryFile
+from django.core.management.base import BaseCommand, CommandError
+from django.core.management.utils import (
+    find_command,
+    handle_extensions,
+    is_ignored_path,
+    popen_wrapper,
+)
+from django.utils.encoding import DEFAULT_LOCALE_ENCODING
+from django.utils.functional import cached_property
+from django.utils.jslex import prepare_js_for_gettext
+from django.utils.regex_helper import _lazy_re_compile
+from django.utils.text import get_text_list
+from django.utils.translation import templatize
+
+plural_forms_re = _lazy_re_compile(
+    r'^(?P<value>"Plural-Forms.+?\\n")\s*$', re.MULTILINE | re.DOTALL
+)
+STATUS_OK = 0
+NO_LOCALE_DIR = object()
+
+
+def check_programs(*programs):
+    for program in programs:
+        if find_command(program) is None:
+            raise CommandError(
+                "Can't find %s. Make sure you have GNU gettext tools 0.15 or "
+                "newer installed." % program
+            )
+
+
+def is_valid_locale(locale):
+    return re.match(r"^[a-z]+$", locale) or re.match(r"^[a-z]+_[A-Z].*$", locale)
+
+
+@total_ordering
+class TranslatableFile:
+    def __init__(self, dirpath, file_name, locale_dir):
+        self.file = file_name
+        self.dirpath = dirpath
+        self.locale_dir = locale_dir
+
+    def __repr__(self):
+        return "<%s: %s>" % (
+            self.__class__.__name__,
+            os.sep.join([self.dirpath, self.file]),
+        )
+
+    def __eq__(self, other):
+        return self.path == other.path
+
+    def __lt__(self, other):
+        return self.path < other.path
+
+    @property
+    def path(self):
+        return os.path.join(self.dirpath, self.file)
+
+
+class BuildFile:
+    """
+    Represent the state of a translatable file during the build process.
+    """
+
+    def __init__(self, command, domain, translatable):
+        self.command = command
+        self.domain = domain
+        self.translatable = translatable
+
+    @cached_property
+    def is_templatized(self):
+        if self.domain == "djangojs":
+            return self.command.gettext_version < (0, 18, 3)
+        elif self.domain == "django":
+            file_ext = os.path.splitext(self.translatable.file)[1]
+            return file_ext != ".py"
+        return False
+
+    @cached_property
+    def path(self):
+        return self.translatable.path
+
+    @cached_property
+    def work_path(self):
+        """
+        Path to a file which is being fed into GNU gettext pipeline. This may
+        be either a translatable or its preprocessed version.
+        """
+        if not self.is_templatized:
+            return self.path
+        extension = {
+            "djangojs": "c",
+            "django": "py",
+        }.get(self.domain)
+        filename = "%s.%s" % (self.translatable.file, extension)
+        return os.path.join(self.translatable.dirpath, filename)
+
+    def preprocess(self):
+        """
+        Preprocess (if necessary) a translatable file before passing it to
+        xgettext GNU gettext utility.
+        """
+        if not self.is_templatized:
+            return
+
+        with open(self.path, encoding="utf-8") as fp:
+            src_data = fp.read()
+
+        if self.domain == "djangojs":
+            content = prepare_js_for_gettext(src_data)
+        elif self.domain == "django":
+            content = templatize(src_data, origin=self.path[2:])
+
+        with open(self.work_path, "w", encoding="utf-8") as fp:
+            fp.write(content)
+
+    def postprocess_messages(self, msgs):
+        """
+        Postprocess messages generated by xgettext GNU gettext utility.
+
+        Transform paths as if these messages were generated from original
+        translatable files rather than from preprocessed versions.
+        """
+        if not self.is_templatized:
+            return msgs
+
+        # Remove '.py' suffix
+        if os.name == "nt":
+            # Preserve '.\' prefix on Windows to respect gettext behavior
+            old_path = self.work_path
+            new_path = self.path
+        else:
+            old_path = self.work_path[2:]
+            new_path = self.path[2:]
+
+        return re.sub(
+            r"^(#: .*)(" + re.escape(old_path) + r")",
+            lambda match: match[0].replace(old_path, new_path),
+            msgs,
+            flags=re.MULTILINE,
+        )
+
+    def cleanup(self):
+        """
+        Remove a preprocessed copy of a translatable file (if any).
+        """
+        if self.is_templatized:
+            # This check is needed for the case of a symlinked file and its
+            # source being processed inside a single group (locale dir);
+            # removing either of those two removes both.
+            if os.path.exists(self.work_path):
+                os.unlink(self.work_path)
+
+
+def normalize_eols(raw_contents):
+    """
+    Take a block of raw text that will be passed through str.splitlines() to
+    get universal newlines treatment.
+
+    Return the resulting block of text with normalized `\n` EOL sequences ready
+    to be written to disk using current platform's native EOLs.
+    """
+    lines_list = raw_contents.splitlines()
+    # Ensure last line has its EOL
+    if lines_list and lines_list[-1]:
+        lines_list.append("")
+    return "\n".join(lines_list)
+
+
+def write_pot_file(potfile, msgs):
+    """
+    Write the `potfile` with the `msgs` contents, making sure its format is
+    valid.
+    """
+    pot_lines = msgs.splitlines()
+    if os.path.exists(potfile):
+        # Strip the header
+        lines = dropwhile(len, pot_lines)
+    else:
+        lines = []
+        found, header_read = False, False
+        for line in pot_lines:
+            if not found and not header_read:
+                if "charset=CHARSET" in line:
+                    found = True
+                    line = line.replace("charset=CHARSET", "charset=UTF-8")
+            if not line and not found:
+                header_read = True
+            lines.append(line)
+    msgs = "\n".join(lines)
+    # Force newlines of POT files to '\n' to work around
+    # https://savannah.gnu.org/bugs/index.php?52395
+    with open(potfile, "a", encoding="utf-8", newline="\n") as fp:
+        fp.write(msgs)
+
+
+class Command(BaseCommand):
+    help = (
+        "Runs over the entire source tree of the current directory and pulls out all "
+        "strings marked for translation. It creates (or updates) a message file in the "
+        "conf/locale (in the django tree) or locale (for projects and applications) "
+        "directory.\n\nYou must run this command with one of either the --locale, "
+        "--exclude, or --all options."
+    )
+
+    translatable_file_class = TranslatableFile
+    build_file_class = BuildFile
+
+    requires_system_checks = []
+
+    msgmerge_options = ["-q", "--backup=none", "--previous", "--update"]
+    msguniq_options = ["--to-code=utf-8"]
+    msgattrib_options = ["--no-obsolete"]
+    xgettext_options = ["--from-code=UTF-8", "--add-comments=Translators"]
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "--locale",
+            "-l",
+            default=[],
+            action="append",
+            help=(
+                "Creates or updates the message files for the given locale(s) (e.g. "
+                "pt_BR). Can be used multiple times."
+            ),
+        )
+        parser.add_argument(
+            "--exclude",
+            "-x",
+            default=[],
+            action="append",
+            help="Locales to exclude. Default is none. Can be used multiple times.",
+        )
+        parser.add_argument(
+            "--domain",
+            "-d",
+            default="django",
+            help='The domain of the message files (default: "django").',
+        )
+        parser.add_argument(
+            "--all",
+            "-a",
+            action="store_true",
+            help="Updates the message files for all existing locales.",
+        )
+        parser.add_argument(
+            "--extension",
+            "-e",
+            dest="extensions",
+            action="append",
+            help='The file extension(s) to examine (default: "html,txt,py", or "js" '
+            'if the domain is "djangojs"). Separate multiple extensions with '
+            "commas, or use -e multiple times.",
+        )
+        parser.add_argument(
+            "--symlinks",
+            "-s",
+            action="store_true",
+            help="Follows symlinks to directories when examining source code "
+            "and templates for translation strings.",
+        )
+        parser.add_argument(
+            "--ignore",
+            "-i",
+            action="append",
+            dest="ignore_patterns",
+            default=[],
+            metavar="PATTERN",
+            help="Ignore files or directories matching this glob-style pattern. "
+            "Use multiple times to ignore more.",
+        )
+        parser.add_argument(
+            "--no-default-ignore",
+            action="store_false",
+            dest="use_default_ignore_patterns",
+            help=(
+                "Don't ignore the common glob-style patterns 'CVS', '.*', '*~' and "
+                "'*.pyc'."
+            ),
+        )
+        parser.add_argument(
+            "--no-wrap",
+            action="store_true",
+            help="Don't break long message lines into several lines.",
+        )
+        parser.add_argument(
+            "--no-location",
+            action="store_true",
+            help="Don't write '#: filename:line' lines.",
+        )
+        parser.add_argument(
+            "--add-location",
+            choices=("full", "file", "never"),
+            const="full",
+            nargs="?",
+            help=(
+                "Controls '#: filename:line' lines. If the option is 'full' "
+                "(the default if not given), the lines  include both file name "
+                "and line number. If it's 'file', the line number is omitted. If "
+                "it's 'never', the lines are suppressed (same as --no-location). "
+                "--add-location requires gettext 0.19 or newer."
+            ),
+        )
+        parser.add_argument(
+            "--no-obsolete",
+            action="store_true",
+            help="Remove obsolete message strings.",
+        )
+        parser.add_argument(
+            "--keep-pot",
+            action="store_true",
+            help="Keep .pot file after making messages. Useful when debugging.",
+        )
+
+    def handle(self, *args, **options):
+        locale = options["locale"]
+        exclude = options["exclude"]
+        self.domain = options["domain"]
+        self.verbosity = options["verbosity"]
+        process_all = options["all"]
+        extensions = options["extensions"]
+        self.symlinks = options["symlinks"]
+
+        ignore_patterns = options["ignore_patterns"]
+        if options["use_default_ignore_patterns"]:
+            ignore_patterns += ["CVS", ".*", "*~", "*.pyc"]
+        self.ignore_patterns = list(set(ignore_patterns))
+
+        # Avoid messing with mutable class variables
+        if options["no_wrap"]:
+            self.msgmerge_options = self.msgmerge_options[:] + ["--no-wrap"]
+            self.msguniq_options = self.msguniq_options[:] + ["--no-wrap"]
+            self.msgattrib_options = self.msgattrib_options[:] + ["--no-wrap"]
+            self.xgettext_options = self.xgettext_options[:] + ["--no-wrap"]
+        if options["no_location"]:
+            self.msgmerge_options = self.msgmerge_options[:] + ["--no-location"]
+            self.msguniq_options = self.msguniq_options[:] + ["--no-location"]
+            self.msgattrib_options = self.msgattrib_options[:] + ["--no-location"]
+            self.xgettext_options = self.xgettext_options[:] + ["--no-location"]
+        if options["add_location"]:
+            if self.gettext_version < (0, 19):
+                raise CommandError(
+                    "The --add-location option requires gettext 0.19 or later. "
+                    "You have %s." % ".".join(str(x) for x in self.gettext_version)
+                )
+            arg_add_location = "--add-location=%s" % options["add_location"]
+            self.msgmerge_options = self.msgmerge_options[:] + [arg_add_location]
+            self.msguniq_options = self.msguniq_options[:] + [arg_add_location]
+            self.msgattrib_options = self.msgattrib_options[:] + [arg_add_location]
+            self.xgettext_options = self.xgettext_options[:] + [arg_add_location]
+
+        self.no_obsolete = options["no_obsolete"]
+        self.keep_pot = options["keep_pot"]
+
+        if self.domain not in ("django", "djangojs"):
+            raise CommandError(
+                "currently makemessages only supports domains "
+                "'django' and 'djangojs'"
+            )
+        if self.domain == "djangojs":
+            exts = extensions or ["js"]
+        else:
+            exts = extensions or ["html", "txt", "py"]
+        self.extensions = handle_extensions(exts)
+
+        if (not locale and not exclude and not process_all) or self.domain is None:
+            raise CommandError(
+                "Type '%s help %s' for usage information."
+                % (os.path.basename(sys.argv[0]), sys.argv[1])
+            )
+
+        if self.verbosity > 1:
+            self.stdout.write(
+                "examining files with the extensions: %s"
+                % get_text_list(list(self.extensions), "and")
+            )
+
+        self.invoked_for_django = False
+        self.locale_paths = []
+        self.default_locale_path = None
+        if os.path.isdir(os.path.join("conf", "locale")):
+            self.locale_paths = [os.path.abspath(os.path.join("conf", "locale"))]
+            self.default_locale_path = self.locale_paths[0]
+            self.invoked_for_django = True
+        else:
+            if self.settings_available:
+                self.locale_paths.extend(settings.LOCALE_PATHS)
+            # Allow to run makemessages inside an app dir
+            if os.path.isdir("locale"):
+                self.locale_paths.append(os.path.abspath("locale"))
+            if self.locale_paths:
+                self.default_locale_path = self.locale_paths[0]
+                os.makedirs(self.default_locale_path, exist_ok=True)
+
+        # Build locale list
+        looks_like_locale = re.compile(r"[a-z]{2}")
+        locale_dirs = filter(
+            os.path.isdir, glob.glob("%s/*" % self.default_locale_path)
+        )
+        all_locales = [
+            lang_code
+            for lang_code in map(os.path.basename, locale_dirs)
+            if looks_like_locale.match(lang_code)
+        ]
+
+        # Account for excluded locales
+        if process_all:
+            locales = all_locales
+        else:
+            locales = locale or all_locales
+            locales = set(locales).difference(exclude)
+
+        if locales:
+            check_programs("msguniq", "msgmerge", "msgattrib")
+
+        check_programs("xgettext")
+
+        try:
+            potfiles = self.build_potfiles()
+
+            # Build po files for each selected locale
+            for locale in locales:
+                if not is_valid_locale(locale):
+                    # Try to guess what valid locale it could be
+                    # Valid examples are: en_GB, shi_Latn_MA and nl_NL-x-informal
+
+                    # Search for characters followed by a non character (i.e. separator)
+                    match = re.match(
+                        r"^(?P<language>[a-zA-Z]+)"
+                        r"(?P<separator>[^a-zA-Z])"
+                        r"(?P<territory>.+)$",
+                        locale,
+                    )
+                    if match:
+                        locale_parts = match.groupdict()
+                        language = locale_parts["language"].lower()
+                        territory = (
+                            locale_parts["territory"][:2].upper()
+                            + locale_parts["territory"][2:]
+                        )
+                        proposed_locale = f"{language}_{territory}"
+                    else:
+                        # It could be a language in uppercase
+                        proposed_locale = locale.lower()
+
+                    # Recheck if the proposed locale is valid
+                    if is_valid_locale(proposed_locale):
+                        self.stdout.write(
+                            "invalid locale %s, did you mean %s?"
+                            % (
+                                locale,
+                                proposed_locale,
+                            ),
+                        )
+                    else:
+                        self.stdout.write("invalid locale %s" % locale)
+
+                    continue
+                if self.verbosity > 0:
+                    self.stdout.write("processing locale %s" % locale)
+                for potfile in potfiles:
+                    self.write_po_file(potfile, locale)
+        finally:
+            if not self.keep_pot:
+                self.remove_potfiles()
+
+    @cached_property
+    def gettext_version(self):
+        # Gettext tools will output system-encoded bytestrings instead of UTF-8,
+        # when looking up the version. It's especially a problem on Windows.
+        out, err, status = popen_wrapper(
+            ["xgettext", "--version"],
+            stdout_encoding=DEFAULT_LOCALE_ENCODING,
+        )
+        m = re.search(r"(\d+)\.(\d+)\.?(\d+)?", out)
+        if m:
+            return tuple(int(d) for d in m.groups() if d is not None)
+        else:
+            raise CommandError("Unable to get gettext version. Is it installed?")
+
+    @cached_property
+    def settings_available(self):
+        try:
+            settings.LOCALE_PATHS
+        except ImproperlyConfigured:
+            if self.verbosity > 1:
+                self.stderr.write("Running without configured settings.")
+            return False
+        return True
+
+    def build_potfiles(self):
+        """
+        Build pot files and apply msguniq to them.
+        """
+        file_list = self.find_files(".")
+        self.remove_potfiles()
+        self.process_files(file_list)
+        potfiles = []
+        for path in self.locale_paths:
+            potfile = os.path.join(path, "%s.pot" % self.domain)
+            if not os.path.exists(potfile):
+                continue
+            args = ["msguniq"] + self.msguniq_options + [potfile]
+            msgs, errors, status = popen_wrapper(args)
+            if errors:
+                if status != STATUS_OK:
+                    raise CommandError(
+                        "errors happened while running msguniq\n%s" % errors
+                    )
+                elif self.verbosity > 0:
+                    self.stdout.write(errors)
+            msgs = normalize_eols(msgs)
+            with open(potfile, "w", encoding="utf-8") as fp:
+                fp.write(msgs)
+            potfiles.append(potfile)
+        return potfiles
+
+    def remove_potfiles(self):
+        for path in self.locale_paths:
+            pot_path = os.path.join(path, "%s.pot" % self.domain)
+            if os.path.exists(pot_path):
+                os.unlink(pot_path)
+
+    def find_files(self, root):
+        """
+        Get all files in the given root. Also check that there is a matching
+        locale dir for each file.
+        """
+        all_files = []
+        ignored_roots = []
+        if self.settings_available:
+            ignored_roots = [
+                os.path.normpath(p)
+                for p in (settings.MEDIA_ROOT, settings.STATIC_ROOT)
+                if p
+            ]
+        for dirpath, dirnames, filenames in os.walk(
+            root, topdown=True, followlinks=self.symlinks
+        ):
+            for dirname in dirnames[:]:
+                if (
+                    is_ignored_path(
+                        os.path.normpath(os.path.join(dirpath, dirname)),
+                        self.ignore_patterns,
+                    )
+                    or os.path.join(os.path.abspath(dirpath), dirname) in ignored_roots
+                ):
+                    dirnames.remove(dirname)
+                    if self.verbosity > 1:
+                        self.stdout.write("ignoring directory %s" % dirname)
+                elif dirname == "locale":
+                    dirnames.remove(dirname)
+                    self.locale_paths.insert(
+                        0, os.path.join(os.path.abspath(dirpath), dirname)
+                    )
+            for filename in filenames:
+                file_path = os.path.normpath(os.path.join(dirpath, filename))
+                file_ext = os.path.splitext(filename)[1]
+                if file_ext not in self.extensions or is_ignored_path(
+                    file_path, self.ignore_patterns
+                ):
+                    if self.verbosity > 1:
+                        self.stdout.write(
+                            "ignoring file %s in %s" % (filename, dirpath)
+                        )
+                else:
+                    locale_dir = None
+                    for path in self.locale_paths:
+                        if os.path.abspath(dirpath).startswith(os.path.dirname(path)):
+                            locale_dir = path
+                            break
+                    locale_dir = locale_dir or self.default_locale_path or NO_LOCALE_DIR
+                    all_files.append(
+                        self.translatable_file_class(dirpath, filename, locale_dir)
+                    )
+        return sorted(all_files)
+
+    def process_files(self, file_list):
+        """
+        Group translatable files by locale directory and run pot file build
+        process for each group.
+        """
+        file_groups = {}
+        for translatable in file_list:
+            file_group = file_groups.setdefault(translatable.locale_dir, [])
+            file_group.append(translatable)
+        for locale_dir, files in file_groups.items():
+            self.process_locale_dir(locale_dir, files)
+
+    def process_locale_dir(self, locale_dir, files):
+        """
+        Extract translatable literals from the specified files, creating or
+        updating the POT file for a given locale directory.
+
+        Use the xgettext GNU gettext utility.
+        """
+        build_files = []
+        for translatable in files:
+            if self.verbosity > 1:
+                self.stdout.write(
+                    "processing file %s in %s"
+                    % (translatable.file, translatable.dirpath)
+                )
+            if self.domain not in ("djangojs", "django"):
+                continue
+            build_file = self.build_file_class(self, self.domain, translatable)
+            try:
+                build_file.preprocess()
+            except UnicodeDecodeError as e:
+                self.stdout.write(
+                    "UnicodeDecodeError: skipped file %s in %s (reason: %s)"
+                    % (
+                        translatable.file,
+                        translatable.dirpath,
+                        e,
+                    )
+                )
+                continue
+            except BaseException:
+                # Cleanup before exit.
+                for build_file in build_files:
+                    build_file.cleanup()
+                raise
+            build_files.append(build_file)
+
+        if self.domain == "djangojs":
+            is_templatized = build_file.is_templatized
+            args = [
+                "xgettext",
+                "-d",
+                self.domain,
+                "--language=%s" % ("C" if is_templatized else "JavaScript",),
+                "--keyword=gettext_noop",
+                "--keyword=gettext_lazy",
+                "--keyword=ngettext_lazy:1,2",
+                "--keyword=pgettext:1c,2",
+                "--keyword=npgettext:1c,2,3",
+                "--output=-",
+            ]
+        elif self.domain == "django":
+            args = [
+                "xgettext",
+                "-d",
+                self.domain,
+                "--language=Python",
+                "--keyword=gettext_noop",
+                "--keyword=gettext_lazy",
+                "--keyword=ngettext_lazy:1,2",
+                "--keyword=pgettext:1c,2",
+                "--keyword=npgettext:1c,2,3",
+                "--keyword=pgettext_lazy:1c,2",
+                "--keyword=npgettext_lazy:1c,2,3",
+                "--output=-",
+            ]
+        else:
+            return
+
+        input_files = [bf.work_path for bf in build_files]
+        with NamedTemporaryFile(mode="w+") as input_files_list:
+            input_files_list.write("\n".join(input_files))
+            input_files_list.flush()
+            args.extend(["--files-from", input_files_list.name])
+            args.extend(self.xgettext_options)
+            msgs, errors, status = popen_wrapper(args)
+
+        if errors:
+            if status != STATUS_OK:
+                for build_file in build_files:
+                    build_file.cleanup()
+                raise CommandError(
+                    "errors happened while running xgettext on %s\n%s"
+                    % ("\n".join(input_files), errors)
+                )
+            elif self.verbosity > 0:
+                # Print warnings
+                self.stdout.write(errors)
+
+        if msgs:
+            if locale_dir is NO_LOCALE_DIR:
+                for build_file in build_files:
+                    build_file.cleanup()
+                file_path = os.path.normpath(build_files[0].path)
+                raise CommandError(
+                    "Unable to find a locale path to store translations for "
+                    "file %s. Make sure the 'locale' directory exists in an "
+                    "app or LOCALE_PATHS setting is set." % file_path
+                )
+            for build_file in build_files:
+                msgs = build_file.postprocess_messages(msgs)
+            potfile = os.path.join(locale_dir, "%s.pot" % self.domain)
+            write_pot_file(potfile, msgs)
+
+        for build_file in build_files:
+            build_file.cleanup()
+
+    def write_po_file(self, potfile, locale):
+        """
+        Create or update the PO file for self.domain and `locale`.
+        Use contents of the existing `potfile`.
+
+        Use msgmerge and msgattrib GNU gettext utilities.
+        """
+        basedir = os.path.join(os.path.dirname(potfile), locale, "LC_MESSAGES")
+        os.makedirs(basedir, exist_ok=True)
+        pofile = os.path.join(basedir, "%s.po" % self.domain)
+
+        if os.path.exists(pofile):
+            args = ["msgmerge"] + self.msgmerge_options + [pofile, potfile]
+            _, errors, status = popen_wrapper(args)
+            if errors:
+                if status != STATUS_OK:
+                    raise CommandError(
+                        "errors happened while running msgmerge\n%s" % errors
+                    )
+                elif self.verbosity > 0:
+                    self.stdout.write(errors)
+            msgs = Path(pofile).read_text(encoding="utf-8")
+        else:
+            with open(potfile, encoding="utf-8") as fp:
+                msgs = fp.read()
+            if not self.invoked_for_django:
+                msgs = self.copy_plural_forms(msgs, locale)
+        msgs = normalize_eols(msgs)
+        msgs = msgs.replace(
+            "#. #-#-#-#-#  %s.pot (PACKAGE VERSION)  #-#-#-#-#\n" % self.domain, ""
+        )
+        with open(pofile, "w", encoding="utf-8") as fp:
+            fp.write(msgs)
+
+        if self.no_obsolete:
+            args = ["msgattrib"] + self.msgattrib_options + ["-o", pofile, pofile]
+            msgs, errors, status = popen_wrapper(args)
+            if errors:
+                if status != STATUS_OK:
+                    raise CommandError(
+                        "errors happened while running msgattrib\n%s" % errors
+                    )
+                elif self.verbosity > 0:
+                    self.stdout.write(errors)
+
+    def copy_plural_forms(self, msgs, locale):
+        """
+        Copy plural forms header contents from a Django catalog of locale to
+        the msgs string, inserting it at the right place. msgs should be the
+        contents of a newly created .po file.
+        """
+        django_dir = os.path.normpath(os.path.join(os.path.dirname(django.__file__)))
+        if self.domain == "djangojs":
+            domains = ("djangojs", "django")
+        else:
+            domains = ("django",)
+        for domain in domains:
+            django_po = os.path.join(
+                django_dir, "conf", "locale", locale, "LC_MESSAGES", "%s.po" % domain
+            )
+            if os.path.exists(django_po):
+                with open(django_po, encoding="utf-8") as fp:
+                    m = plural_forms_re.search(fp.read())
+                if m:
+                    plural_form_line = m["value"]
+                    if self.verbosity > 1:
+                        self.stdout.write("copying plural forms: %s" % plural_form_line)
+                    lines = []
+                    found = False
+                    for line in msgs.splitlines():
+                        if not found and (not line or plural_forms_re.search(line)):
+                            line = plural_form_line
+                            found = True
+                        lines.append(line)
+                    msgs = "\n".join(lines)
+                    break
+        return msgs
diff --git a/tests/corpus/.refactoring-benchmark/migrate.py b/tests/corpus/.refactoring-benchmark/migrate.py
new file mode 100644
index 0000000..1541843
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/migrate.py
@@ -0,0 +1,511 @@
+import sys
+import time
+from importlib import import_module
+
+from django.apps import apps
+from django.core.management.base import BaseCommand, CommandError, no_translations
+from django.core.management.sql import emit_post_migrate_signal, emit_pre_migrate_signal
+from django.db import DEFAULT_DB_ALIAS, connections, router
+from django.db.migrations.autodetector import MigrationAutodetector
+from django.db.migrations.executor import MigrationExecutor
+from django.db.migrations.loader import AmbiguityError
+from django.db.migrations.state import ModelState, ProjectState
+from django.utils.module_loading import module_has_submodule
+from django.utils.text import Truncator
+
+
+class Command(BaseCommand):
+    help = (
+        "Updates database schema. Manages both apps with migrations and those without."
+    )
+    requires_system_checks = []
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "--skip-checks",
+            action="store_true",
+            help="Skip system checks.",
+        )
+        parser.add_argument(
+            "app_label",
+            nargs="?",
+            help="App label of an application to synchronize the state.",
+        )
+        parser.add_argument(
+            "migration_name",
+            nargs="?",
+            help="Database state will be brought to the state after that "
+            'migration. Use the name "zero" to unapply all migrations.',
+        )
+        parser.add_argument(
+            "--noinput",
+            "--no-input",
+            action="store_false",
+            dest="interactive",
+            help="Tells Django to NOT prompt the user for input of any kind.",
+        )
+        parser.add_argument(
+            "--database",
+            default=DEFAULT_DB_ALIAS,
+            help=(
+                'Nominates a database to synchronize. Defaults to the "default" '
+                "database."
+            ),
+        )
+        parser.add_argument(
+            "--fake",
+            action="store_true",
+            help="Mark migrations as run without actually running them.",
+        )
+        parser.add_argument(
+            "--fake-initial",
+            action="store_true",
+            help=(
+                "Detect if tables already exist and fake-apply initial migrations if "
+                "so. Make sure that the current database schema matches your initial "
+                "migration before using this flag. Django will only check for an "
+                "existing table name."
+            ),
+        )
+        parser.add_argument(
+            "--plan",
+            action="store_true",
+            help="Shows a list of the migration actions that will be performed.",
+        )
+        parser.add_argument(
+            "--run-syncdb",
+            action="store_true",
+            help="Creates tables for apps without migrations.",
+        )
+        parser.add_argument(
+            "--check",
+            action="store_true",
+            dest="check_unapplied",
+            help=(
+                "Exits with a non-zero status if unapplied migrations exist and does "
+                "not actually apply migrations."
+            ),
+        )
+        parser.add_argument(
+            "--prune",
+            action="store_true",
+            dest="prune",
+            help="Delete nonexistent migrations from the django_migrations table.",
+        )
+
+    @no_translations
+    def handle(self, *args, **options):
+        database = options["database"]
+        if not options["skip_checks"]:
+            self.check(databases=[database])
+
+        self.verbosity = options["verbosity"]
+        self.interactive = options["interactive"]
+
+        # Import the 'management' module within each installed app, to register
+        # dispatcher events.
+        for app_config in apps.get_app_configs():
+            if module_has_submodule(app_config.module, "management"):
+                import_module(".management", app_config.name)
+
+        # Get the database we're operating from
+        connection = connections[database]
+
+        # Hook for backends needing any database preparation
+        connection.prepare_database()
+        # Work out which apps have migrations and which do not
+        executor = MigrationExecutor(connection, self.migration_progress_callback)
+
+        # Raise an error if any migrations are applied before their dependencies.
+        executor.loader.check_consistent_history(connection)
+
+        # Before anything else, see if there's conflicting apps and drop out
+        # hard if there are any
+        conflicts = executor.loader.detect_conflicts()
+        if conflicts:
+            name_str = "; ".join(
+                "%s in %s" % (", ".join(names), app) for app, names in conflicts.items()
+            )
+            raise CommandError(
+                "Conflicting migrations detected; multiple leaf nodes in the "
+                "migration graph: (%s).\nTo fix them run "
+                "'python manage.py makemigrations --merge'" % name_str
+            )
+
+        # If they supplied command line arguments, work out what they mean.
+        run_syncdb = options["run_syncdb"]
+        target_app_labels_only = True
+        if options["app_label"]:
+            # Validate app_label.
+            app_label = options["app_label"]
+            try:
+                apps.get_app_config(app_label)
+            except LookupError as err:
+                raise CommandError(str(err))
+            if run_syncdb:
+                if app_label in executor.loader.migrated_apps:
+                    raise CommandError(
+                        "Can't use run_syncdb with app '%s' as it has migrations."
+                        % app_label
+                    )
+            elif app_label not in executor.loader.migrated_apps:
+                raise CommandError("App '%s' does not have migrations." % app_label)
+
+        if options["app_label"] and options["migration_name"]:
+            migration_name = options["migration_name"]
+            if migration_name == "zero":
+                targets = [(app_label, None)]
+            else:
+                try:
+                    migration = executor.loader.get_migration_by_prefix(
+                        app_label, migration_name
+                    )
+                except AmbiguityError:
+                    raise CommandError(
+                        "More than one migration matches '%s' in app '%s'. "
+                        "Please be more specific." % (migration_name, app_label)
+                    )
+                except KeyError:
+                    raise CommandError(
+                        "Cannot find a migration matching '%s' from app '%s'."
+                        % (migration_name, app_label)
+                    )
+                target = (app_label, migration.name)
+                # Partially applied squashed migrations are not included in the
+                # graph, use the last replacement instead.
+                if (
+                    target not in executor.loader.graph.nodes
+                    and target in executor.loader.replacements
+                ):
+                    incomplete_migration = executor.loader.replacements[target]
+                    target = incomplete_migration.replaces[-1]
+                targets = [target]
+            target_app_labels_only = False
+        elif options["app_label"]:
+            targets = [
+                key for key in executor.loader.graph.leaf_nodes() if key[0] == app_label
+            ]
+        else:
+            targets = executor.loader.graph.leaf_nodes()
+
+        if options["prune"]:
+            if not options["app_label"]:
+                raise CommandError(
+                    "Migrations can be pruned only when an app is specified."
+                )
+            if self.verbosity > 0:
+                self.stdout.write("Pruning migrations:", self.style.MIGRATE_HEADING)
+            to_prune = set(executor.loader.applied_migrations) - set(
+                executor.loader.disk_migrations
+            )
+            squashed_migrations_with_deleted_replaced_migrations = [
+                migration_key
+                for migration_key, migration_obj in executor.loader.replacements.items()
+                if any(replaced in to_prune for replaced in migration_obj.replaces)
+            ]
+            if squashed_migrations_with_deleted_replaced_migrations:
+                self.stdout.write(
+                    self.style.NOTICE(
+                        "  Cannot use --prune because the following squashed "
+                        "migrations have their 'replaces' attributes and may not "
+                        "be recorded as applied:"
+                    )
+                )
+                for migration in squashed_migrations_with_deleted_replaced_migrations:
+                    app, name = migration
+                    self.stdout.write(f"    {app}.{name}")
+                self.stdout.write(
+                    self.style.NOTICE(
+                        "  Re-run 'manage.py migrate' if they are not marked as "
+                        "applied, and remove 'replaces' attributes in their "
+                        "Migration classes."
+                    )
+                )
+            else:
+                to_prune = sorted(
+                    migration for migration in to_prune if migration[0] == app_label
+                )
+                if to_prune:
+                    for migration in to_prune:
+                        app, name = migration
+                        if self.verbosity > 0:
+                            self.stdout.write(
+                                self.style.MIGRATE_LABEL(f"  Pruning {app}.{name}"),
+                                ending="",
+                            )
+                        executor.recorder.record_unapplied(app, name)
+                        if self.verbosity > 0:
+                            self.stdout.write(self.style.SUCCESS(" OK"))
+                elif self.verbosity > 0:
+                    self.stdout.write("  No migrations to prune.")
+
+        plan = executor.migration_plan(targets)
+
+        if options["plan"]:
+            self.stdout.write("Planned operations:", self.style.MIGRATE_LABEL)
+            if not plan:
+                self.stdout.write("  No planned migration operations.")
+            else:
+                for migration, backwards in plan:
+                    self.stdout.write(str(migration), self.style.MIGRATE_HEADING)
+                    for operation in migration.operations:
+                        message, is_error = self.describe_operation(
+                            operation, backwards
+                        )
+                        style = self.style.WARNING if is_error else None
+                        self.stdout.write("    " + message, style)
+                if options["check_unapplied"]:
+                    sys.exit(1)
+            return
+        if options["check_unapplied"]:
+            if plan:
+                sys.exit(1)
+            return
+        if options["prune"]:
+            return
+
+        # At this point, ignore run_syncdb if there aren't any apps to sync.
+        run_syncdb = options["run_syncdb"] and executor.loader.unmigrated_apps
+        # Print some useful info
+        if self.verbosity >= 1:
+            self.stdout.write(self.style.MIGRATE_HEADING("Operations to perform:"))
+            if run_syncdb:
+                if options["app_label"]:
+                    self.stdout.write(
+                        self.style.MIGRATE_LABEL(
+                            "  Synchronize unmigrated app: %s" % app_label
+                        )
+                    )
+                else:
+                    self.stdout.write(
+                        self.style.MIGRATE_LABEL("  Synchronize unmigrated apps: ")
+                        + (", ".join(sorted(executor.loader.unmigrated_apps)))
+                    )
+            if target_app_labels_only:
+                self.stdout.write(
+                    self.style.MIGRATE_LABEL("  Apply all migrations: ")
+                    + (", ".join(sorted({a for a, n in targets})) or "(none)")
+                )
+            else:
+                if targets[0][1] is None:
+                    self.stdout.write(
+                        self.style.MIGRATE_LABEL("  Unapply all migrations: ")
+                        + str(targets[0][0])
+                    )
+                else:
+                    self.stdout.write(
+                        self.style.MIGRATE_LABEL("  Target specific migration: ")
+                        + "%s, from %s" % (targets[0][1], targets[0][0])
+                    )
+
+        pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
+        pre_migrate_apps = pre_migrate_state.apps
+        emit_pre_migrate_signal(
+            self.verbosity,
+            self.interactive,
+            connection.alias,
+            stdout=self.stdout,
+            apps=pre_migrate_apps,
+            plan=plan,
+        )
+
+        # Run the syncdb phase.
+        if run_syncdb:
+            if self.verbosity >= 1:
+                self.stdout.write(
+                    self.style.MIGRATE_HEADING("Synchronizing apps without migrations:")
+                )
+            if options["app_label"]:
+                self.sync_apps(connection, [app_label])
+            else:
+                self.sync_apps(connection, executor.loader.unmigrated_apps)
+
+        # Migrate!
+        if self.verbosity >= 1:
+            self.stdout.write(self.style.MIGRATE_HEADING("Running migrations:"))
+        if not plan:
+            if self.verbosity >= 1:
+                self.stdout.write("  No migrations to apply.")
+                # If there's changes that aren't in migrations yet, tell them
+                # how to fix it.
+                autodetector = MigrationAutodetector(
+                    executor.loader.project_state(),
+                    ProjectState.from_apps(apps),
+                )
+                changes = autodetector.changes(graph=executor.loader.graph)
+                if changes:
+                    self.stdout.write(
+                        self.style.NOTICE(
+                            "  Your models in app(s): %s have changes that are not "
+                            "yet reflected in a migration, and so won't be "
+                            "applied." % ", ".join(repr(app) for app in sorted(changes))
+                        )
+                    )
+                    self.stdout.write(
+                        self.style.NOTICE(
+                            "  Run 'manage.py makemigrations' to make new "
+                            "migrations, and then re-run 'manage.py migrate' to "
+                            "apply them."
+                        )
+                    )
+            fake = False
+            fake_initial = False
+        else:
+            fake = options["fake"]
+            fake_initial = options["fake_initial"]
+        post_migrate_state = executor.migrate(
+            targets,
+            plan=plan,
+            state=pre_migrate_state.clone(),
+            fake=fake,
+            fake_initial=fake_initial,
+        )
+        # post_migrate signals have access to all models. Ensure that all models
+        # are reloaded in case any are delayed.
+        post_migrate_state.clear_delayed_apps_cache()
+        post_migrate_apps = post_migrate_state.apps
+
+        # Re-render models of real apps to include relationships now that
+        # we've got a final state. This wouldn't be necessary if real apps
+        # models were rendered with relationships in the first place.
+        with post_migrate_apps.bulk_update():
+            model_keys = []
+            for model_state in post_migrate_apps.real_models:
+                model_key = model_state.app_label, model_state.name_lower
+                model_keys.append(model_key)
+                post_migrate_apps.unregister_model(*model_key)
+        post_migrate_apps.render_multiple(
+            [ModelState.from_model(apps.get_model(*model)) for model in model_keys]
+        )
+
+        # Send the post_migrate signal, so individual apps can do whatever they need
+        # to do at this point.
+        emit_post_migrate_signal(
+            self.verbosity,
+            self.interactive,
+            connection.alias,
+            stdout=self.stdout,
+            apps=post_migrate_apps,
+            plan=plan,
+        )
+
+    def migration_progress_callback(self, action, migration=None, fake=False):
+        if self.verbosity >= 1:
+            compute_time = self.verbosity > 1
+            if action == "apply_start":
+                if compute_time:
+                    self.start = time.monotonic()
+                self.stdout.write("  Applying %s..." % migration, ending="")
+                self.stdout.flush()
+            elif action == "apply_success":
+                elapsed = (
+                    " (%.3fs)" % (time.monotonic() - self.start) if compute_time else ""
+                )
+                if fake:
+                    self.stdout.write(self.style.SUCCESS(" FAKED" + elapsed))
+                else:
+                    self.stdout.write(self.style.SUCCESS(" OK" + elapsed))
+            elif action == "unapply_start":
+                if compute_time:
+                    self.start = time.monotonic()
+                self.stdout.write("  Unapplying %s..." % migration, ending="")
+                self.stdout.flush()
+            elif action == "unapply_success":
+                elapsed = (
+                    " (%.3fs)" % (time.monotonic() - self.start) if compute_time else ""
+                )
+                if fake:
+                    self.stdout.write(self.style.SUCCESS(" FAKED" + elapsed))
+                else:
+                    self.stdout.write(self.style.SUCCESS(" OK" + elapsed))
+            elif action == "render_start":
+                if compute_time:
+                    self.start = time.monotonic()
+                self.stdout.write("  Rendering model states...", ending="")
+                self.stdout.flush()
+            elif action == "render_success":
+                elapsed = (
+                    " (%.3fs)" % (time.monotonic() - self.start) if compute_time else ""
+                )
+                self.stdout.write(self.style.SUCCESS(" DONE" + elapsed))
+
+    def sync_apps(self, connection, app_labels):
+        """Run the old syncdb-style operation on a list of app_labels."""
+        with connection.cursor() as cursor:
+            tables = connection.introspection.table_names(cursor)
+
+        # Build the manifest of apps and models that are to be synchronized.
+        all_models = [
+            (
+                app_config.label,
+                router.get_migratable_models(
+                    app_config, connection.alias, include_auto_created=False
+                ),
+            )
+            for app_config in apps.get_app_configs()
+            if app_config.models_module is not None and app_config.label in app_labels
+        ]
+
+        def model_installed(model):
+            opts = model._meta
+            converter = connection.introspection.identifier_converter
+            return not (
+                (converter(opts.db_table) in tables)
+                or (
+                    opts.auto_created
+                    and converter(opts.auto_created._meta.db_table) in tables
+                )
+            )
+
+        manifest = {
+            app_name: list(filter(model_installed, model_list))
+            for app_name, model_list in all_models
+        }
+
+        # Create the tables for each model
+        if self.verbosity >= 1:
+            self.stdout.write("  Creating tables...")
+        with connection.schema_editor() as editor:
+            for app_name, model_list in manifest.items():
+                for model in model_list:
+                    # Never install unmanaged models, etc.
+                    if not model._meta.can_migrate(connection):
+                        continue
+                    if self.verbosity >= 3:
+                        self.stdout.write(
+                            "    Processing %s.%s model"
+                            % (app_name, model._meta.object_name)
+                        )
+                    if self.verbosity >= 1:
+                        self.stdout.write(
+                            "    Creating table %s" % model._meta.db_table
+                        )
+                    editor.create_model(model)
+
+            # Deferred SQL is executed when exiting the editor's context.
+            if self.verbosity >= 1:
+                self.stdout.write("    Running deferred SQL...")
+
+    @staticmethod
+    def describe_operation(operation, backwards):
+        """Return a string that describes a migration operation for --plan."""
+        prefix = ""
+        is_error = False
+        if hasattr(operation, "code"):
+            code = operation.reverse_code if backwards else operation.code
+            action = (code.__doc__ or "") if code else None
+        elif hasattr(operation, "sql"):
+            action = operation.reverse_sql if backwards else operation.sql
+        else:
+            action = ""
+            if backwards:
+                prefix = "Undo "
+        if action is not None:
+            action = str(action).replace("\n", "")
+        elif backwards:
+            action = "IRREVERSIBLE"
+            is_error = True
+        if action:
+            action = " -> " + action
+        truncated = Truncator(action)
+        return prefix + operation.describe() + truncated.chars(40), is_error
diff --git a/tests/corpus/.refactoring-benchmark/operations.py b/tests/corpus/.refactoring-benchmark/operations.py
new file mode 100644
index 0000000..78f9981
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/operations.py
@@ -0,0 +1,726 @@
+import datetime
+import uuid
+from functools import lru_cache
+
+from django.conf import settings
+from django.db import DatabaseError, NotSupportedError
+from django.db.backends.base.operations import BaseDatabaseOperations
+from django.db.backends.utils import split_tzname_delta, strip_quotes, truncate_name
+from django.db.models import AutoField, Exists, ExpressionWrapper, Lookup
+from django.db.models.expressions import RawSQL
+from django.db.models.sql.where import WhereNode
+from django.utils import timezone
+from django.utils.encoding import force_bytes, force_str
+from django.utils.functional import cached_property
+from django.utils.regex_helper import _lazy_re_compile
+
+from .base import Database
+from .utils import BulkInsertMapper, InsertVar, Oracle_datetime
+
+
+class DatabaseOperations(BaseDatabaseOperations):
+    # Oracle uses NUMBER(5), NUMBER(11), and NUMBER(19) for integer fields.
+    # SmallIntegerField uses NUMBER(11) instead of NUMBER(5), which is used by
+    # SmallAutoField, to preserve backward compatibility.
+    integer_field_ranges = {
+        "SmallIntegerField": (-99999999999, 99999999999),
+        "IntegerField": (-99999999999, 99999999999),
+        "BigIntegerField": (-9999999999999999999, 9999999999999999999),
+        "PositiveBigIntegerField": (0, 9999999999999999999),
+        "PositiveSmallIntegerField": (0, 99999999999),
+        "PositiveIntegerField": (0, 99999999999),
+        "SmallAutoField": (-99999, 99999),
+        "AutoField": (-99999999999, 99999999999),
+        "BigAutoField": (-9999999999999999999, 9999999999999999999),
+    }
+    set_operators = {**BaseDatabaseOperations.set_operators, "difference": "MINUS"}
+
+    # TODO: colorize this SQL code with style.SQL_KEYWORD(), etc.
+    _sequence_reset_sql = """
+DECLARE
+    table_value integer;
+    seq_value integer;
+    seq_name user_tab_identity_cols.sequence_name%%TYPE;
+BEGIN
+    BEGIN
+        SELECT sequence_name INTO seq_name FROM user_tab_identity_cols
+        WHERE  table_name = '%(table_name)s' AND
+               column_name = '%(column_name)s';
+        EXCEPTION WHEN NO_DATA_FOUND THEN
+            seq_name := '%(no_autofield_sequence_name)s';
+    END;
+
+    SELECT NVL(MAX(%(column)s), 0) INTO table_value FROM %(table)s;
+    SELECT NVL(last_number - cache_size, 0) INTO seq_value FROM user_sequences
+           WHERE sequence_name = seq_name;
+    WHILE table_value > seq_value LOOP
+        EXECUTE IMMEDIATE 'SELECT "'||seq_name||'".nextval FROM DUAL'
+        INTO seq_value;
+    END LOOP;
+END;
+/"""
+
+    # Oracle doesn't support string without precision; use the max string size.
+    cast_char_field_without_max_length = "NVARCHAR2(2000)"
+    cast_data_types = {
+        "AutoField": "NUMBER(11)",
+        "BigAutoField": "NUMBER(19)",
+        "SmallAutoField": "NUMBER(5)",
+        "TextField": cast_char_field_without_max_length,
+    }
+
+    def cache_key_culling_sql(self):
+        cache_key = self.quote_name("cache_key")
+        return (
+            f"SELECT {cache_key} "
+            f"FROM %s "
+            f"ORDER BY {cache_key} OFFSET %%s ROWS FETCH FIRST 1 ROWS ONLY"
+        )
+
+    # EXTRACT format cannot be passed in parameters.
+    _extract_format_re = _lazy_re_compile(r"[A-Z_]+")
+
+    def date_extract_sql(self, lookup_type, sql, params):
+        extract_sql = f"TO_CHAR({sql}, %s)"
+        extract_param = None
+        if lookup_type == "week_day":
+            # TO_CHAR(field, 'D') returns an integer from 1-7, where 1=Sunday.
+            extract_param = "D"
+        elif lookup_type == "iso_week_day":
+            extract_sql = f"TO_CHAR({sql} - 1, %s)"
+            extract_param = "D"
+        elif lookup_type == "week":
+            # IW = ISO week number
+            extract_param = "IW"
+        elif lookup_type == "quarter":
+            extract_param = "Q"
+        elif lookup_type == "iso_year":
+            extract_param = "IYYY"
+        else:
+            lookup_type = lookup_type.upper()
+            if not self._extract_format_re.fullmatch(lookup_type):
+                raise ValueError(f"Invalid loookup type: {lookup_type!r}")
+            # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/EXTRACT-datetime.html
+            return f"EXTRACT({lookup_type} FROM {sql})", params
+        return extract_sql, (*params, extract_param)
+
+    def date_trunc_sql(self, lookup_type, sql, params, tzname=None):
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/ROUND-and-TRUNC-Date-Functions.html
+        trunc_param = None
+        if lookup_type in ("year", "month"):
+            trunc_param = lookup_type.upper()
+        elif lookup_type == "quarter":
+            trunc_param = "Q"
+        elif lookup_type == "week":
+            trunc_param = "IW"
+        else:
+            return f"TRUNC({sql})", params
+        return f"TRUNC({sql}, %s)", (*params, trunc_param)
+
+    # Oracle crashes with "ORA-03113: end-of-file on communication channel"
+    # if the time zone name is passed in parameter. Use interpolation instead.
+    # https://groups.google.com/forum/#!msg/django-developers/zwQju7hbG78/9l934yelwfsJ
+    # This regexp matches all time zone names from the zoneinfo database.
+    _tzname_re = _lazy_re_compile(r"^[\w/:+-]+$")
+
+    def _prepare_tzname_delta(self, tzname):
+        tzname, sign, offset = split_tzname_delta(tzname)
+        return f"{sign}{offset}" if offset else tzname
+
+    def _convert_sql_to_tz(self, sql, params, tzname):
+        if not (settings.USE_TZ and tzname):
+            return sql, params
+        if not self._tzname_re.match(tzname):
+            raise ValueError("Invalid time zone name: %s" % tzname)
+        # Convert from connection timezone to the local time, returning
+        # TIMESTAMP WITH TIME ZONE and cast it back to TIMESTAMP to strip the
+        # TIME ZONE details.
+        if self.connection.timezone_name != tzname:
+            from_timezone_name = self.connection.timezone_name
+            to_timezone_name = self._prepare_tzname_delta(tzname)
+            return (
+                f"CAST((FROM_TZ({sql}, '{from_timezone_name}') AT TIME ZONE "
+                f"'{to_timezone_name}') AS TIMESTAMP)",
+                params,
+            )
+        return sql, params
+
+    def datetime_cast_date_sql(self, sql, params, tzname):
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        return f"TRUNC({sql})", params
+
+    def datetime_cast_time_sql(self, sql, params, tzname):
+        # Since `TimeField` values are stored as TIMESTAMP change to the
+        # default date and convert the field to the specified timezone.
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        convert_datetime_sql = (
+            f"TO_TIMESTAMP(CONCAT('1900-01-01 ', TO_CHAR({sql}, 'HH24:MI:SS.FF')), "
+            f"'YYYY-MM-DD HH24:MI:SS.FF')"
+        )
+        return (
+            f"CASE WHEN {sql} IS NOT NULL THEN {convert_datetime_sql} ELSE NULL END",
+            (*params, *params),
+        )
+
+    def datetime_extract_sql(self, lookup_type, sql, params, tzname):
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        return self.date_extract_sql(lookup_type, sql, params)
+
+    def datetime_trunc_sql(self, lookup_type, sql, params, tzname):
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/ROUND-and-TRUNC-Date-Functions.html
+        trunc_param = None
+        if lookup_type in ("year", "month"):
+            trunc_param = lookup_type.upper()
+        elif lookup_type == "quarter":
+            trunc_param = "Q"
+        elif lookup_type == "week":
+            trunc_param = "IW"
+        elif lookup_type == "hour":
+            trunc_param = "HH24"
+        elif lookup_type == "minute":
+            trunc_param = "MI"
+        elif lookup_type == "day":
+            return f"TRUNC({sql})", params
+        else:
+            # Cast to DATE removes sub-second precision.
+            return f"CAST({sql} AS DATE)", params
+        return f"TRUNC({sql}, %s)", (*params, trunc_param)
+
+    def time_trunc_sql(self, lookup_type, sql, params, tzname=None):
+        # The implementation is similar to `datetime_trunc_sql` as both
+        # `DateTimeField` and `TimeField` are stored as TIMESTAMP where
+        # the date part of the later is ignored.
+        sql, params = self._convert_sql_to_tz(sql, params, tzname)
+        trunc_param = None
+        if lookup_type == "hour":
+            trunc_param = "HH24"
+        elif lookup_type == "minute":
+            trunc_param = "MI"
+        elif lookup_type == "second":
+            # Cast to DATE removes sub-second precision.
+            return f"CAST({sql} AS DATE)", params
+        return f"TRUNC({sql}, %s)", (*params, trunc_param)
+
+    def get_db_converters(self, expression):
+        converters = super().get_db_converters(expression)
+        internal_type = expression.output_field.get_internal_type()
+        if internal_type in ["JSONField", "TextField"]:
+            converters.append(self.convert_textfield_value)
+        elif internal_type == "BinaryField":
+            converters.append(self.convert_binaryfield_value)
+        elif internal_type == "BooleanField":
+            converters.append(self.convert_booleanfield_value)
+        elif internal_type == "DateTimeField":
+            if settings.USE_TZ:
+                converters.append(self.convert_datetimefield_value)
+        elif internal_type == "DateField":
+            converters.append(self.convert_datefield_value)
+        elif internal_type == "TimeField":
+            converters.append(self.convert_timefield_value)
+        elif internal_type == "UUIDField":
+            converters.append(self.convert_uuidfield_value)
+        # Oracle stores empty strings as null. If the field accepts the empty
+        # string, undo this to adhere to the Django convention of using
+        # the empty string instead of null.
+        if expression.output_field.empty_strings_allowed:
+            converters.append(
+                self.convert_empty_bytes
+                if internal_type == "BinaryField"
+                else self.convert_empty_string
+            )
+        return converters
+
+    def convert_textfield_value(self, value, expression, connection):
+        if isinstance(value, Database.LOB):
+            value = value.read()
+        return value
+
+    def convert_binaryfield_value(self, value, expression, connection):
+        if isinstance(value, Database.LOB):
+            value = force_bytes(value.read())
+        return value
+
+    def convert_booleanfield_value(self, value, expression, connection):
+        if value in (0, 1):
+            value = bool(value)
+        return value
+
+    # cx_Oracle always returns datetime.datetime objects for
+    # DATE and TIMESTAMP columns, but Django wants to see a
+    # python datetime.date, .time, or .datetime.
+
+    def convert_datetimefield_value(self, value, expression, connection):
+        if value is not None:
+            value = timezone.make_aware(value, self.connection.timezone)
+        return value
+
+    def convert_datefield_value(self, value, expression, connection):
+        if isinstance(value, Database.Timestamp):
+            value = value.date()
+        return value
+
+    def convert_timefield_value(self, value, expression, connection):
+        if isinstance(value, Database.Timestamp):
+            value = value.time()
+        return value
+
+    def convert_uuidfield_value(self, value, expression, connection):
+        if value is not None:
+            value = uuid.UUID(value)
+        return value
+
+    @staticmethod
+    def convert_empty_string(value, expression, connection):
+        return "" if value is None else value
+
+    @staticmethod
+    def convert_empty_bytes(value, expression, connection):
+        return b"" if value is None else value
+
+    def deferrable_sql(self):
+        return " DEFERRABLE INITIALLY DEFERRED"
+
+    def fetch_returned_insert_columns(self, cursor, returning_params):
+        columns = []
+        for param in returning_params:
+            value = param.get_value()
+            if value == []:
+                raise DatabaseError(
+                    "The database did not return a new row id. Probably "
+                    '"ORA-1403: no data found" was raised internally but was '
+                    "hidden by the Oracle OCI library (see "
+                    "https://code.djangoproject.com/ticket/28859)."
+                )
+            columns.append(value[0])
+        return tuple(columns)
+
+    def field_cast_sql(self, db_type, internal_type):
+        if db_type and db_type.endswith("LOB") and internal_type != "JSONField":
+            return "DBMS_LOB.SUBSTR(%s)"
+        else:
+            return "%s"
+
+    def no_limit_value(self):
+        return None
+
+    def limit_offset_sql(self, low_mark, high_mark):
+        fetch, offset = self._get_limit_offset_params(low_mark, high_mark)
+        return " ".join(
+            sql
+            for sql in (
+                ("OFFSET %d ROWS" % offset) if offset else None,
+                ("FETCH FIRST %d ROWS ONLY" % fetch) if fetch else None,
+            )
+            if sql
+        )
+
+    def last_executed_query(self, cursor, sql, params):
+        # https://cx-oracle.readthedocs.io/en/latest/api_manual/cursor.html#Cursor.statement
+        # The DB API definition does not define this attribute.
+        statement = cursor.statement
+        # Unlike Psycopg's `query` and MySQLdb`'s `_executed`, cx_Oracle's
+        # `statement` doesn't contain the query parameters. Substitute
+        # parameters manually.
+        if isinstance(params, (tuple, list)):
+            for i, param in enumerate(reversed(params), start=1):
+                param_num = len(params) - i
+                statement = statement.replace(
+                    ":arg%d" % param_num, force_str(param, errors="replace")
+                )
+        elif isinstance(params, dict):
+            for key in sorted(params, key=len, reverse=True):
+                statement = statement.replace(
+                    ":%s" % key, force_str(params[key], errors="replace")
+                )
+        return statement
+
+    def last_insert_id(self, cursor, table_name, pk_name):
+        sq_name = self._get_sequence_name(cursor, strip_quotes(table_name), pk_name)
+        cursor.execute('"%s".currval' % sq_name)
+        return cursor.fetchone()[0]
+
+    def lookup_cast(self, lookup_type, internal_type=None):
+        if lookup_type in ("iexact", "icontains", "istartswith", "iendswith"):
+            return "UPPER(%s)"
+        if internal_type == "JSONField" and lookup_type == "exact":
+            return "DBMS_LOB.SUBSTR(%s)"
+        return "%s"
+
+    def max_in_list_size(self):
+        return 1000
+
+    def max_name_length(self):
+        return 30
+
+    def pk_default_value(self):
+        return "NULL"
+
+    def prep_for_iexact_query(self, x):
+        return x
+
+    def process_clob(self, value):
+        if value is None:
+            return ""
+        return value.read()
+
+    def quote_name(self, name):
+        # SQL92 requires delimited (quoted) names to be case-sensitive.  When
+        # not quoted, Oracle has case-insensitive behavior for identifiers, but
+        # always defaults to uppercase.
+        # We simplify things by making Oracle identifiers always uppercase.
+        if not name.startswith('"') and not name.endswith('"'):
+            name = '"%s"' % truncate_name(name, self.max_name_length())
+        # Oracle puts the query text into a (query % args) construct, so % signs
+        # in names need to be escaped. The '%%' will be collapsed back to '%' at
+        # that stage so we aren't really making the name longer here.
+        name = name.replace("%", "%%")
+        return name.upper()
+
+    def regex_lookup(self, lookup_type):
+        if lookup_type == "regex":
+            match_option = "'c'"
+        else:
+            match_option = "'i'"
+        return "REGEXP_LIKE(%%s, %%s, %s)" % match_option
+
+    def return_insert_columns(self, fields):
+        if not fields:
+            return "", ()
+        field_names = []
+        params = []
+        for field in fields:
+            field_names.append(
+                "%s.%s"
+                % (
+                    self.quote_name(field.model._meta.db_table),
+                    self.quote_name(field.column),
+                )
+            )
+            params.append(InsertVar(field))
+        return "RETURNING %s INTO %s" % (
+            ", ".join(field_names),
+            ", ".join(["%s"] * len(params)),
+        ), tuple(params)
+
+    def __foreign_key_constraints(self, table_name, recursive):
+        with self.connection.cursor() as cursor:
+            if recursive:
+                cursor.execute(
+                    """
+                    SELECT
+                        user_tables.table_name, rcons.constraint_name
+                    FROM
+                        user_tables
+                    JOIN
+                        user_constraints cons
+                        ON (user_tables.table_name = cons.table_name
+                        AND cons.constraint_type = ANY('P', 'U'))
+                    LEFT JOIN
+                        user_constraints rcons
+                        ON (user_tables.table_name = rcons.table_name
+                        AND rcons.constraint_type = 'R')
+                    START WITH user_tables.table_name = UPPER(%s)
+                    CONNECT BY
+                        NOCYCLE PRIOR cons.constraint_name = rcons.r_constraint_name
+                    GROUP BY
+                        user_tables.table_name, rcons.constraint_name
+                    HAVING user_tables.table_name != UPPER(%s)
+                    ORDER BY MAX(level) DESC
+                    """,
+                    (table_name, table_name),
+                )
+            else:
+                cursor.execute(
+                    """
+                    SELECT
+                        cons.table_name, cons.constraint_name
+                    FROM
+                        user_constraints cons
+                    WHERE
+                        cons.constraint_type = 'R'
+                        AND cons.table_name = UPPER(%s)
+                    """,
+                    (table_name,),
+                )
+            return cursor.fetchall()
+
+    @cached_property
+    def _foreign_key_constraints(self):
+        # 512 is large enough to fit the ~330 tables (as of this writing) in
+        # Django's test suite.
+        return lru_cache(maxsize=512)(self.__foreign_key_constraints)
+
+    def sql_flush(self, style, tables, *, reset_sequences=False, allow_cascade=False):
+        if not tables:
+            return []
+
+        truncated_tables = {table.upper() for table in tables}
+        constraints = set()
+        # Oracle's TRUNCATE CASCADE only works with ON DELETE CASCADE foreign
+        # keys which Django doesn't define. Emulate the PostgreSQL behavior
+        # which truncates all dependent tables by manually retrieving all
+        # foreign key constraints and resolving dependencies.
+        for table in tables:
+            for foreign_table, constraint in self._foreign_key_constraints(
+                table, recursive=allow_cascade
+            ):
+                if allow_cascade:
+                    truncated_tables.add(foreign_table)
+                constraints.add((foreign_table, constraint))
+        sql = (
+            [
+                "%s %s %s %s %s %s %s %s;"
+                % (
+                    style.SQL_KEYWORD("ALTER"),
+                    style.SQL_KEYWORD("TABLE"),
+                    style.SQL_FIELD(self.quote_name(table)),
+                    style.SQL_KEYWORD("DISABLE"),
+                    style.SQL_KEYWORD("CONSTRAINT"),
+                    style.SQL_FIELD(self.quote_name(constraint)),
+                    style.SQL_KEYWORD("KEEP"),
+                    style.SQL_KEYWORD("INDEX"),
+                )
+                for table, constraint in constraints
+            ]
+            + [
+                "%s %s %s;"
+                % (
+                    style.SQL_KEYWORD("TRUNCATE"),
+                    style.SQL_KEYWORD("TABLE"),
+                    style.SQL_FIELD(self.quote_name(table)),
+                )
+                for table in truncated_tables
+            ]
+            + [
+                "%s %s %s %s %s %s;"
+                % (
+                    style.SQL_KEYWORD("ALTER"),
+                    style.SQL_KEYWORD("TABLE"),
+                    style.SQL_FIELD(self.quote_name(table)),
+                    style.SQL_KEYWORD("ENABLE"),
+                    style.SQL_KEYWORD("CONSTRAINT"),
+                    style.SQL_FIELD(self.quote_name(constraint)),
+                )
+                for table, constraint in constraints
+            ]
+        )
+        if reset_sequences:
+            sequences = [
+                sequence
+                for sequence in self.connection.introspection.sequence_list()
+                if sequence["table"].upper() in truncated_tables
+            ]
+            # Since we've just deleted all the rows, running our sequence ALTER
+            # code will reset the sequence to 0.
+            sql.extend(self.sequence_reset_by_name_sql(style, sequences))
+        return sql
+
+    def sequence_reset_by_name_sql(self, style, sequences):
+        sql = []
+        for sequence_info in sequences:
+            no_autofield_sequence_name = self._get_no_autofield_sequence_name(
+                sequence_info["table"]
+            )
+            table = self.quote_name(sequence_info["table"])
+            column = self.quote_name(sequence_info["column"] or "id")
+            query = self._sequence_reset_sql % {
+                "no_autofield_sequence_name": no_autofield_sequence_name,
+                "table": table,
+                "column": column,
+                "table_name": strip_quotes(table),
+                "column_name": strip_quotes(column),
+            }
+            sql.append(query)
+        return sql
+
+    def sequence_reset_sql(self, style, model_list):
+        output = []
+        query = self._sequence_reset_sql
+        for model in model_list:
+            for f in model._meta.local_fields:
+                if isinstance(f, AutoField):
+                    no_autofield_sequence_name = self._get_no_autofield_sequence_name(
+                        model._meta.db_table
+                    )
+                    table = self.quote_name(model._meta.db_table)
+                    column = self.quote_name(f.column)
+                    output.append(
+                        query
+                        % {
+                            "no_autofield_sequence_name": no_autofield_sequence_name,
+                            "table": table,
+                            "column": column,
+                            "table_name": strip_quotes(table),
+                            "column_name": strip_quotes(column),
+                        }
+                    )
+                    # Only one AutoField is allowed per model, so don't
+                    # continue to loop
+                    break
+        return output
+
+    def start_transaction_sql(self):
+        return ""
+
+    def tablespace_sql(self, tablespace, inline=False):
+        if inline:
+            return "USING INDEX TABLESPACE %s" % self.quote_name(tablespace)
+        else:
+            return "TABLESPACE %s" % self.quote_name(tablespace)
+
+    def adapt_datefield_value(self, value):
+        """
+        Transform a date value to an object compatible with what is expected
+        by the backend driver for date columns.
+        The default implementation transforms the date to text, but that is not
+        necessary for Oracle.
+        """
+        return value
+
+    def adapt_datetimefield_value(self, value):
+        """
+        Transform a datetime value to an object compatible with what is expected
+        by the backend driver for datetime columns.
+
+        If naive datetime is passed assumes that is in UTC. Normally Django
+        models.DateTimeField makes sure that if USE_TZ is True passed datetime
+        is timezone aware.
+        """
+
+        if value is None:
+            return None
+
+        # Expression values are adapted by the database.
+        if hasattr(value, "resolve_expression"):
+            return value
+
+        # cx_Oracle doesn't support tz-aware datetimes
+        if timezone.is_aware(value):
+            if settings.USE_TZ:
+                value = timezone.make_naive(value, self.connection.timezone)
+            else:
+                raise ValueError(
+                    "Oracle backend does not support timezone-aware datetimes when "
+                    "USE_TZ is False."
+                )
+
+        return Oracle_datetime.from_datetime(value)
+
+    def adapt_timefield_value(self, value):
+        if value is None:
+            return None
+
+        # Expression values are adapted by the database.
+        if hasattr(value, "resolve_expression"):
+            return value
+
+        if isinstance(value, str):
+            return datetime.datetime.strptime(value, "%H:%M:%S")
+
+        # Oracle doesn't support tz-aware times
+        if timezone.is_aware(value):
+            raise ValueError("Oracle backend does not support timezone-aware times.")
+
+        return Oracle_datetime(
+            1900, 1, 1, value.hour, value.minute, value.second, value.microsecond
+        )
+
+    def adapt_decimalfield_value(self, value, max_digits=None, decimal_places=None):
+        return value
+
+    def combine_expression(self, connector, sub_expressions):
+        lhs, rhs = sub_expressions
+        if connector == "%%":
+            return "MOD(%s)" % ",".join(sub_expressions)
+        elif connector == "&":
+            return "BITAND(%s)" % ",".join(sub_expressions)
+        elif connector == "|":
+            return "BITAND(-%(lhs)s-1,%(rhs)s)+%(lhs)s" % {"lhs": lhs, "rhs": rhs}
+        elif connector == "<<":
+            return "(%(lhs)s * POWER(2, %(rhs)s))" % {"lhs": lhs, "rhs": rhs}
+        elif connector == ">>":
+            return "FLOOR(%(lhs)s / POWER(2, %(rhs)s))" % {"lhs": lhs, "rhs": rhs}
+        elif connector == "^":
+            return "POWER(%s)" % ",".join(sub_expressions)
+        elif connector == "#":
+            raise NotSupportedError("Bitwise XOR is not supported in Oracle.")
+        return super().combine_expression(connector, sub_expressions)
+
+    def _get_no_autofield_sequence_name(self, table):
+        """
+        Manually created sequence name to keep backward compatibility for
+        AutoFields that aren't Oracle identity columns.
+        """
+        name_length = self.max_name_length() - 3
+        return "%s_SQ" % truncate_name(strip_quotes(table), name_length).upper()
+
+    def _get_sequence_name(self, cursor, table, pk_name):
+        cursor.execute(
+            """
+            SELECT sequence_name
+            FROM user_tab_identity_cols
+            WHERE table_name = UPPER(%s)
+            AND column_name = UPPER(%s)""",
+            [table, pk_name],
+        )
+        row = cursor.fetchone()
+        return self._get_no_autofield_sequence_name(table) if row is None else row[0]
+
+    def bulk_insert_sql(self, fields, placeholder_rows):
+        query = []
+        for row in placeholder_rows:
+            select = []
+            for i, placeholder in enumerate(row):
+                # A model without any fields has fields=[None].
+                if fields[i]:
+                    internal_type = getattr(
+                        fields[i], "target_field", fields[i]
+                    ).get_internal_type()
+                    placeholder = (
+                        BulkInsertMapper.types.get(internal_type, "%s") % placeholder
+                    )
+                # Add columns aliases to the first select to avoid "ORA-00918:
+                # column ambiguously defined" when two or more columns in the
+                # first select have the same value.
+                if not query:
+                    placeholder = "%s col_%s" % (placeholder, i)
+                select.append(placeholder)
+            query.append("SELECT %s FROM DUAL" % ", ".join(select))
+        # Bulk insert to tables with Oracle identity columns causes Oracle to
+        # add sequence.nextval to it. Sequence.nextval cannot be used with the
+        # UNION operator. To prevent incorrect SQL, move UNION to a subquery.
+        return "SELECT * FROM (%s)" % " UNION ALL ".join(query)
+
+    def subtract_temporals(self, internal_type, lhs, rhs):
+        if internal_type == "DateField":
+            lhs_sql, lhs_params = lhs
+            rhs_sql, rhs_params = rhs
+            params = (*lhs_params, *rhs_params)
+            return (
+                "NUMTODSINTERVAL(TO_NUMBER(%s - %s), 'DAY')" % (lhs_sql, rhs_sql),
+                params,
+            )
+        return super().subtract_temporals(internal_type, lhs, rhs)
+
+    def bulk_batch_size(self, fields, objs):
+        """Oracle restricts the number of parameters in a query."""
+        if fields:
+            return self.connection.features.max_query_params // len(fields)
+        return len(objs)
+
+    def conditional_expression_supported_in_where_clause(self, expression):
+        """
+        Oracle supports only EXISTS(...) or filters in the WHERE clause, others
+        must be compared with True.
+        """
+        if isinstance(expression, (Exists, Lookup, WhereNode)):
+            return True
+        if isinstance(expression, ExpressionWrapper) and expression.conditional:
+            return self.conditional_expression_supported_in_where_clause(
+                expression.expression
+            )
+        if isinstance(expression, RawSQL) and expression.conditional:
+            return True
+        return False
diff --git a/tests/corpus/.refactoring-benchmark/special.py b/tests/corpus/.refactoring-benchmark/special.py
new file mode 100644
index 0000000..94a6ec7
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/special.py
@@ -0,0 +1,208 @@
+from django.db import router
+
+from .base import Operation
+
+
+class SeparateDatabaseAndState(Operation):
+    """
+    Take two lists of operations - ones that will be used for the database,
+    and ones that will be used for the state change. This allows operations
+    that don't support state change to have it applied, or have operations
+    that affect the state or not the database, or so on.
+    """
+
+    serialization_expand_args = ["database_operations", "state_operations"]
+
+    def __init__(self, database_operations=None, state_operations=None):
+        self.database_operations = database_operations or []
+        self.state_operations = state_operations or []
+
+    def deconstruct(self):
+        kwargs = {}
+        if self.database_operations:
+            kwargs["database_operations"] = self.database_operations
+        if self.state_operations:
+            kwargs["state_operations"] = self.state_operations
+        return (self.__class__.__qualname__, [], kwargs)
+
+    def state_forwards(self, app_label, state):
+        for state_operation in self.state_operations:
+            state_operation.state_forwards(app_label, state)
+
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
+        # We calculate state separately in here since our state functions aren't useful
+        for database_operation in self.database_operations:
+            to_state = from_state.clone()
+            database_operation.state_forwards(app_label, to_state)
+            database_operation.database_forwards(
+                app_label, schema_editor, from_state, to_state
+            )
+            from_state = to_state
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        # We calculate state separately in here since our state functions aren't useful
+        to_states = {}
+        for dbop in self.database_operations:
+            to_states[dbop] = to_state
+            to_state = to_state.clone()
+            dbop.state_forwards(app_label, to_state)
+        # to_state now has the states of all the database_operations applied
+        # which is the from_state for the backwards migration of the last
+        # operation.
+        for database_operation in reversed(self.database_operations):
+            from_state = to_state
+            to_state = to_states[database_operation]
+            database_operation.database_backwards(
+                app_label, schema_editor, from_state, to_state
+            )
+
+    def describe(self):
+        return "Custom state/database change combination"
+
+
+class RunSQL(Operation):
+    """
+    Run some raw SQL. A reverse SQL statement may be provided.
+
+    Also accept a list of operations that represent the state change effected
+    by this SQL change, in case it's custom column/table creation/deletion.
+    """
+
+    noop = ""
+
+    def __init__(
+        self, sql, reverse_sql=None, state_operations=None, hints=None, elidable=False
+    ):
+        self.sql = sql
+        self.reverse_sql = reverse_sql
+        self.state_operations = state_operations or []
+        self.hints = hints or {}
+        self.elidable = elidable
+
+    def deconstruct(self):
+        kwargs = {
+            "sql": self.sql,
+        }
+        if self.reverse_sql is not None:
+            kwargs["reverse_sql"] = self.reverse_sql
+        if self.state_operations:
+            kwargs["state_operations"] = self.state_operations
+        if self.hints:
+            kwargs["hints"] = self.hints
+        return (self.__class__.__qualname__, [], kwargs)
+
+    @property
+    def reversible(self):
+        return self.reverse_sql is not None
+
+    def state_forwards(self, app_label, state):
+        for state_operation in self.state_operations:
+            state_operation.state_forwards(app_label, state)
+
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
+        if router.allow_migrate(
+            schema_editor.connection.alias, app_label, **self.hints
+        ):
+            self._run_sql(schema_editor, self.sql)
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        if self.reverse_sql is None:
+            raise NotImplementedError("You cannot reverse this operation")
+        if router.allow_migrate(
+            schema_editor.connection.alias, app_label, **self.hints
+        ):
+            self._run_sql(schema_editor, self.reverse_sql)
+
+    def describe(self):
+        return "Raw SQL operation"
+
+    def _run_sql(self, schema_editor, sqls):
+        if isinstance(sqls, (list, tuple)):
+            for sql in sqls:
+                params = None
+                if isinstance(sql, (list, tuple)):
+                    elements = len(sql)
+                    if elements == 2:
+                        sql, params = sql
+                    else:
+                        raise ValueError("Expected a 2-tuple but got %d" % elements)
+                schema_editor.execute(sql, params=params)
+        elif sqls != RunSQL.noop:
+            statements = schema_editor.connection.ops.prepare_sql_script(sqls)
+            for statement in statements:
+                schema_editor.execute(statement, params=None)
+
+
+class RunPython(Operation):
+    """
+    Run Python code in a context suitable for doing versioned ORM operations.
+    """
+
+    reduces_to_sql = False
+
+    def __init__(
+        self, code, reverse_code=None, atomic=None, hints=None, elidable=False
+    ):
+        self.atomic = atomic
+        # Forwards code
+        if not callable(code):
+            raise ValueError("RunPython must be supplied with a callable")
+        self.code = code
+        # Reverse code
+        if reverse_code is None:
+            self.reverse_code = None
+        else:
+            if not callable(reverse_code):
+                raise ValueError("RunPython must be supplied with callable arguments")
+            self.reverse_code = reverse_code
+        self.hints = hints or {}
+        self.elidable = elidable
+
+    def deconstruct(self):
+        kwargs = {
+            "code": self.code,
+        }
+        if self.reverse_code is not None:
+            kwargs["reverse_code"] = self.reverse_code
+        if self.atomic is not None:
+            kwargs["atomic"] = self.atomic
+        if self.hints:
+            kwargs["hints"] = self.hints
+        return (self.__class__.__qualname__, [], kwargs)
+
+    @property
+    def reversible(self):
+        return self.reverse_code is not None
+
+    def state_forwards(self, app_label, state):
+        # RunPython objects have no state effect. To add some, combine this
+        # with SeparateDatabaseAndState.
+        pass
+
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
+        # RunPython has access to all models. Ensure that all models are
+        # reloaded in case any are delayed.
+        from_state.clear_delayed_apps_cache()
+        if router.allow_migrate(
+            schema_editor.connection.alias, app_label, **self.hints
+        ):
+            # We now execute the Python code in a context that contains a 'models'
+            # object, representing the versioned models as an app registry.
+            # We could try to override the global cache, but then people will still
+            # use direct imports, so we go with a documentation approach instead.
+            self.code(from_state.apps, schema_editor)
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        if self.reverse_code is None:
+            raise NotImplementedError("You cannot reverse this operation")
+        if router.allow_migrate(
+            schema_editor.connection.alias, app_label, **self.hints
+        ):
+            self.reverse_code(from_state.apps, schema_editor)
+
+    def describe(self):
+        return "Raw Python operation"
+
+    @staticmethod
+    def noop(apps, schema_editor):
+        return None
diff --git a/tests/corpus/.refactoring-benchmark/weather.py b/tests/corpus/.refactoring-benchmark/weather.py
new file mode 100644
index 0000000..c65ba5d
--- /dev/null
+++ b/tests/corpus/.refactoring-benchmark/weather.py
@@ -0,0 +1,355 @@
+"""Support for NWS weather service."""
+from __future__ import annotations
+
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Any, cast
+
+from homeassistant.components.weather import (
+    ATTR_CONDITION_CLEAR_NIGHT,
+    ATTR_CONDITION_SUNNY,
+    ATTR_FORECAST_CONDITION,
+    ATTR_FORECAST_HUMIDITY,
+    ATTR_FORECAST_IS_DAYTIME,
+    ATTR_FORECAST_NATIVE_DEW_POINT,
+    ATTR_FORECAST_NATIVE_TEMP,
+    ATTR_FORECAST_NATIVE_WIND_SPEED,
+    ATTR_FORECAST_PRECIPITATION_PROBABILITY,
+    ATTR_FORECAST_TIME,
+    ATTR_FORECAST_WIND_BEARING,
+    DOMAIN as WEATHER_DOMAIN,
+    CoordinatorWeatherEntity,
+    Forecast,
+    WeatherEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+    CONF_LATITUDE,
+    CONF_LONGITUDE,
+    UnitOfLength,
+    UnitOfPressure,
+    UnitOfSpeed,
+    UnitOfTemperature,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util.dt import utcnow
+from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter
+
+from . import NWSData, base_unique_id, device_info
+from .const import (
+    ATTR_FORECAST_DETAILED_DESCRIPTION,
+    ATTRIBUTION,
+    CONDITION_CLASSES,
+    DAYNIGHT,
+    DOMAIN,
+    FORECAST_VALID_TIME,
+    HOURLY,
+    OBSERVATION_VALID_TIME,
+)
+
+PARALLEL_UPDATES = 0
+
+
+def convert_condition(time: str, weather: tuple[tuple[str, int | None], ...]) -> str:
+    """Convert NWS codes to HA condition.
+
+    Choose first condition in CONDITION_CLASSES that exists in weather code.
+    If no match is found, return first condition from NWS
+    """
+    conditions: list[str] = [w[0] for w in weather]
+
+    # Choose condition with highest priority.
+    cond = next(
+        (
+            key
+            for key, value in CONDITION_CLASSES.items()
+            if any(condition in value for condition in conditions)
+        ),
+        conditions[0],
+    )
+
+    if cond == "clear":
+        if time == "day":
+            return ATTR_CONDITION_SUNNY
+        if time == "night":
+            return ATTR_CONDITION_CLEAR_NIGHT
+    return cond
+
+
+async def async_setup_entry(
+    hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+    """Set up the NWS weather platform."""
+    entity_registry = er.async_get(hass)
+    nws_data: NWSData = hass.data[DOMAIN][entry.entry_id]
+
+    entities = [NWSWeather(entry.data, nws_data, DAYNIGHT)]
+
+    # Add hourly entity to legacy config entries
+    if entity_registry.async_get_entity_id(
+        WEATHER_DOMAIN,
+        DOMAIN,
+        _calculate_unique_id(entry.data, HOURLY),
+    ):
+        entities.append(NWSWeather(entry.data, nws_data, HOURLY))
+
+    async_add_entities(entities, False)
+
+
+if TYPE_CHECKING:
+
+    class NWSForecast(Forecast):
+        """Forecast with extra fields needed for NWS."""
+
+        detailed_description: str | None
+
+
+def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str:
+    """Calculate unique ID."""
+    latitude = entry_data[CONF_LATITUDE]
+    longitude = entry_data[CONF_LONGITUDE]
+    return f"{base_unique_id(latitude, longitude)}_{mode}"
+
+
+def _forecast(
+    self, nws_forecast: list[dict[str, Any]] | None, mode: str
+) -> list[Forecast] | None:
+    """Return forecast."""
+    if nws_forecast is None:
+        return None
+    forecast: list[Forecast] = []
+    for forecast_entry in nws_forecast:
+        data: NWSForecast = {
+            ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get(
+                "detailedForecast"
+            ),
+            ATTR_FORECAST_TIME: cast(str, forecast_entry.get("startTime")),
+        }
+
+        if (temp := forecast_entry.get("temperature")) is not None:
+            data[ATTR_FORECAST_NATIVE_TEMP] = TemperatureConverter.convert(
+                temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
+            )
+        else:
+            data[ATTR_FORECAST_NATIVE_TEMP] = None
+
+        data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = forecast_entry.get(
+            "probabilityOfPrecipitation"
+        )
+
+        if (dewp := forecast_entry.get("dewpoint")) is not None:
+            data[ATTR_FORECAST_NATIVE_DEW_POINT] = TemperatureConverter.convert(
+                dewp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
+            )
+        else:
+            data[ATTR_FORECAST_NATIVE_DEW_POINT] = None
+
+        data[ATTR_FORECAST_HUMIDITY] = forecast_entry.get("relativeHumidity")
+
+        if mode == DAYNIGHT:
+            data[ATTR_FORECAST_IS_DAYTIME] = forecast_entry.get("isDaytime")
+
+        time = forecast_entry.get("iconTime")
+        weather = forecast_entry.get("iconWeather")
+        data[ATTR_FORECAST_CONDITION] = (
+            convert_condition(time, weather) if time and weather else None
+        )
+
+        data[ATTR_FORECAST_WIND_BEARING] = forecast_entry.get("windBearing")
+        wind_speed = forecast_entry.get("windSpeedAvg")
+        if wind_speed is not None:
+            data[ATTR_FORECAST_NATIVE_WIND_SPEED] = SpeedConverter.convert(
+                wind_speed,
+                UnitOfSpeed.MILES_PER_HOUR,
+                UnitOfSpeed.KILOMETERS_PER_HOUR,
+            )
+        else:
+            data[ATTR_FORECAST_NATIVE_WIND_SPEED] = None
+        forecast.append(data)
+    return forecast
+class NWSWeather(CoordinatorWeatherEntity):
+    """Representation of a weather condition."""
+
+    _attr_attribution = ATTRIBUTION
+    _attr_should_poll = False
+    _attr_supported_features = (
+        WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_TWICE_DAILY
+    )
+    _attr_native_temperature_unit = UnitOfTemperature.CELSIUS
+    _attr_native_pressure_unit = UnitOfPressure.PA
+    _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
+    _attr_native_visibility_unit = UnitOfLength.METERS
+
+    def __init__(
+        self,
+        entry_data: MappingProxyType[str, Any],
+        nws_data: NWSData,
+        mode: str,
+    ) -> None:
+        """Initialise the platform with a data instance and station name."""
+        super().__init__(
+            observation_coordinator=nws_data.coordinator_observation,
+            hourly_coordinator=nws_data.coordinator_forecast_hourly,
+            twice_daily_coordinator=nws_data.coordinator_forecast,
+            hourly_forecast_valid=FORECAST_VALID_TIME,
+            twice_daily_forecast_valid=FORECAST_VALID_TIME,
+        )
+        self.nws = nws_data.api
+        latitude = entry_data[CONF_LATITUDE]
+        longitude = entry_data[CONF_LONGITUDE]
+        if mode == DAYNIGHT:
+            self.coordinator_forecast_legacy = nws_data.coordinator_forecast
+        else:
+            self.coordinator_forecast_legacy = nws_data.coordinator_forecast_hourly
+        self.station = self.nws.station
+
+        self.mode = mode
+        self._attr_entity_registry_enabled_default = mode == DAYNIGHT
+
+        self.observation: dict[str, Any] | None = None
+        self._forecast_hourly: list[dict[str, Any]] | None = None
+        self._forecast_legacy: list[dict[str, Any]] | None = None
+        self._forecast_twice_daily: list[dict[str, Any]] | None = None
+
+        self._attr_unique_id = _calculate_unique_id(entry_data, mode)
+        self._attr_device_info = device_info(latitude, longitude)
+        self._attr_name = f"{self.station} {self.mode.title()}"
+
+    async def async_added_to_hass(self) -> None:
+        """Set up a listener and load data."""
+        await super().async_added_to_hass()
+        self.async_on_remove(
+            self.coordinator_forecast_legacy.async_add_listener(
+                self._handle_legacy_forecast_coordinator_update
+            )
+        )
+        # Load initial data from coordinators
+        self._handle_coordinator_update()
+        self._handle_hourly_forecast_coordinator_update()
+        self._handle_twice_daily_forecast_coordinator_update()
+        self._handle_legacy_forecast_coordinator_update()
+
+    @callback
+    def _handle_coordinator_update(self) -> None:
+        """Load data from integration."""
+        self.observation = self.nws.observation
+        self.async_write_ha_state()
+
+    @callback
+    def _handle_hourly_forecast_coordinator_update(self) -> None:
+        """Handle updated data from the hourly forecast coordinator."""
+        self._forecast_hourly = self.nws.forecast_hourly
+
+    @callback
+    def _handle_twice_daily_forecast_coordinator_update(self) -> None:
+        """Handle updated data from the twice daily forecast coordinator."""
+        self._forecast_twice_daily = self.nws.forecast
+
+    @callback
+    def _handle_legacy_forecast_coordinator_update(self) -> None:
+        """Handle updated data from the legacy forecast coordinator."""
+        if self.mode == DAYNIGHT:
+            self._forecast_legacy = self.nws.forecast
+        else:
+            self._forecast_legacy = self.nws.forecast_hourly
+        self.async_write_ha_state()
+
+    @property
+    def native_temperature(self) -> float | None:
+        """Return the current temperature."""
+        if self.observation:
+            return self.observation.get("temperature")
+        return None
+
+    @property
+    def native_pressure(self) -> int | None:
+        """Return the current pressure."""
+        if self.observation:
+            return self.observation.get("seaLevelPressure")
+        return None
+
+    @property
+    def humidity(self) -> float | None:
+        """Return the name of the sensor."""
+        if self.observation:
+            return self.observation.get("relativeHumidity")
+        return None
+
+    @property
+    def native_wind_speed(self) -> float | None:
+        """Return the current windspeed."""
+        if self.observation:
+            return self.observation.get("windSpeed")
+        return None
+
+    @property
+    def wind_bearing(self) -> int | None:
+        """Return the current wind bearing (degrees)."""
+        if self.observation:
+            return self.observation.get("windDirection")
+        return None
+
+    @property
+    def condition(self) -> str | None:
+        """Return current condition."""
+        weather = None
+        if self.observation:
+            weather = self.observation.get("iconWeather")
+            time = cast(str, self.observation.get("iconTime"))
+
+        if weather:
+            return convert_condition(time, weather)
+        return None
+
+    @property
+    def native_visibility(self) -> int | None:
+        """Return visibility."""
+        if self.observation:
+            return self.observation.get("visibility")
+        return None
+
+
+    @property
+    def forecast(self) -> list[Forecast] | None:
+        """Return forecast."""
+        return _forecast(self._forecast_legacy, self.mode)
+
+    @callback
+    def _async_forecast_hourly(self) -> list[Forecast] | None:
+        """Return the hourly forecast in native units."""
+        return _forecast(self._forecast_hourly, HOURLY)
+
+    @callback
+    def _async_forecast_twice_daily(self) -> list[Forecast] | None:
+        """Return the twice daily forecast in native units."""
+        return _forecast(self._forecast_twice_daily, DAYNIGHT)
+
+    @property
+    def available(self) -> bool:
+        """Return if state is available."""
+        last_success = (
+            self.coordinator.last_update_success
+            and self.coordinator_forecast_legacy.last_update_success
+        )
+        if (
+            self.coordinator.last_update_success_time
+            and self.coordinator_forecast_legacy.last_update_success_time
+        ):
+            last_success_time = (
+                utcnow() - self.coordinator.last_update_success_time
+                < OBSERVATION_VALID_TIME
+                and utcnow() - self.coordinator_forecast_legacy.last_update_success_time
+                < FORECAST_VALID_TIME
+            )
+        else:
+            last_success_time = False
+        return last_success or last_success_time
+
+    async def async_update(self) -> None:
+        """Update the entity.
+
+        Only used by the generic entity update service.
+        """
+        await self.coordinator.async_request_refresh()
+        await self.coordinator_forecast_legacy.async_request_refresh()
diff --git a/tests/corpus/.update.identifier.replace.parameters/chat.xml b/tests/corpus/.update.identifier.replace.parameters/chat.xml
new file mode 100644
index 0000000..f72f03d
--- /dev/null
+++ b/tests/corpus/.update.identifier.replace.parameters/chat.xml
@@ -0,0 +1,9 @@
+<no-train>
+```CEDARScript
+UPDATE FUNCTION "calculate" FROM FILE "math_ops.py"
+REPLACE PARAMETERS
+WITH CONTENT '''
+@0:a: float, b: float, operation: str = "add"
+'''
+```
+</no-train>
diff --git a/tests/corpus/.update.identifier.replace.parameters/expected.main.py b/tests/corpus/.update.identifier.replace.parameters/expected.main.py
new file mode 100644
index 0000000..e1383c7
--- /dev/null
+++ b/tests/corpus/.update.identifier.replace.parameters/expected.main.py
@@ -0,0 +1,6 @@
+class Calculator:
+    pass
+def calculate(a: float, b: float, operation: str = "add"):
+    return a + b
+def helper():
+    pass
\ No newline at end of file
diff --git a/tests/corpus/.update.identifier.replace.parameters/main.py b/tests/corpus/.update.identifier.replace.parameters/main.py
new file mode 100644
index 0000000..230ac9e
--- /dev/null
+++ b/tests/corpus/.update.identifier.replace.parameters/main.py
@@ -0,0 +1,9 @@
+class Calculator:
+    pass
+def calculate(
+        a,
+        b
+):
+    return a + b
+def helper():
+    pass
\ No newline at end of file
diff --git a/tests/corpus/apply-relative-indents/chat.xml b/tests/corpus/apply-relative-indents/chat.xml
new file mode 100644
index 0000000..3a6ba33
--- /dev/null
+++ b/tests/corpus/apply-relative-indents/chat.xml
@@ -0,0 +1,47 @@
+<no-train>
+```CEDARScript
+UPDATE METHOD "Luhn.__init__"
+FROM FILE "luhn.py"
+REPLACE BODY
+WITH CONTENT r'''
+            self.card_num = card_num.replace(" ", "")
+''';
+
+UPDATE METHOD "Luhn.valid"
+FROM FILE "luhn.py"
+REPLACE BODY
+WITH CONTENT r'''
+            if len(self.card_num) <= 1:
+                if 1 < 1
+                    if 1 < 1:
+                        return True
+                return False
+                # \x
+            return checksum % 10 == 0
+''';
+```
+
+```CEDARScript
+UPDATE METHOD "Luhn.__init__"
+FROM FILE "luhn2.py"
+REPLACE BODY
+WITH CONTENT r'''
+@0:self.card_num = card_num.replace(" ", "")
+''';
+
+UPDATE METHOD "Luhn.valid"
+FROM FILE "luhn2.py"
+REPLACE BODY
+WITH CONTENT r'''
+@0:if len(self.card_num) <= 1:
+@1:if 1 < 1
+@2:if 1 < 1:
+@3:return True
+@1:return False
+@1:# \x
+@0:return checksum % 10 == 0
+''';
+
+```
+
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/apply-relative-indents/expected.luhn.py b/tests/corpus/apply-relative-indents/expected.luhn.py
new file mode 100644
index 0000000..80b5ab4
--- /dev/null
+++ b/tests/corpus/apply-relative-indents/expected.luhn.py
@@ -0,0 +1,13 @@
+class A:
+    class Luhn:
+        def __init__(self, card_num):
+            self.card_num = card_num.replace(" ", "")
+
+        def valid(self):
+            if len(self.card_num) <= 1:
+                if 1 < 1
+                    if 1 < 1:
+                        return True
+                return False
+                # \x
+            return checksum % 10 == 0
diff --git a/tests/corpus/apply-relative-indents/expected.luhn2.py b/tests/corpus/apply-relative-indents/expected.luhn2.py
new file mode 100644
index 0000000..80b5ab4
--- /dev/null
+++ b/tests/corpus/apply-relative-indents/expected.luhn2.py
@@ -0,0 +1,13 @@
+class A:
+    class Luhn:
+        def __init__(self, card_num):
+            self.card_num = card_num.replace(" ", "")
+
+        def valid(self):
+            if len(self.card_num) <= 1:
+                if 1 < 1
+                    if 1 < 1:
+                        return True
+                return False
+                # \x
+            return checksum % 10 == 0
diff --git a/tests/corpus/apply-relative-indents/luhn.py b/tests/corpus/apply-relative-indents/luhn.py
new file mode 100644
index 0000000..b6805fe
--- /dev/null
+++ b/tests/corpus/apply-relative-indents/luhn.py
@@ -0,0 +1,7 @@
+class A:
+    class Luhn:
+        def __init__(self, card_num):
+            pass
+
+        def valid(self):
+            pass
diff --git a/tests/corpus/apply-relative-indents/luhn2.py b/tests/corpus/apply-relative-indents/luhn2.py
new file mode 100644
index 0000000..b6805fe
--- /dev/null
+++ b/tests/corpus/apply-relative-indents/luhn2.py
@@ -0,0 +1,7 @@
+class A:
+    class Luhn:
+        def __init__(self, card_num):
+            pass
+
+        def valid(self):
+            pass
diff --git a/tests/corpus/chat.make-top-level-from-method.1/chat.xml b/tests/corpus/chat.make-top-level-from-method.1/chat.xml
new file mode 100644
index 0000000..9fd81e1
--- /dev/null
+++ b/tests/corpus/chat.make-top-level-from-method.1/chat.xml
@@ -0,0 +1,38 @@
+<train>
+<user>
+Refactor the `_candidate` method to be a stand alone, top level function.
+</user>
+<assistant>
+Here's my plan to implement the requested changes:
+1. Move the `_candidate()` method from the `A` class, placing it at the top level, just before the line where its class starts;
+2. Update the copied function to remove references to `self`;
+3. Update ALL call sites of the method `_candidate()` to call the new top-level function with the same name;
+
+Here's the CEDARScript script:
+
+```CEDARScript
+# 1. Move the `_candidate()` method from the `A` class, placing it at the top level, just before the line where its class starts
+UPDATE CLASS "A"
+FROM FILE "main.py"
+MOVE FUNCTION "_candidate"
+INSERT BEFORE CLASS "A"
+RELATIVE INDENTATION 0;
+
+# 2. Update the copied function to remove references to `self`
+UPDATE FUNCTION "_candidate"
+FROM FILE "main.py"
+REPLACE LINE "def _candidate(self, a, b, c):"
+WITH CONTENT '''
+@0:def _candidate(a, b, c):
+''';
+
+# 3. Update ALL call sites of the method `_candidate()` to call the new top-level function with the same name
+UPDATE FUNCTION "_check"
+FROM FILE "main.py"
+REPLACE LINE "self._candidate("
+WITH CONTENT '''
+@0:_candidate(
+''';
+```
+</assistant>
+</train>
diff --git a/tests/corpus/chat.make-top-level-from-method.1/expected.main.py b/tests/corpus/chat.make-top-level-from-method.1/expected.main.py
new file mode 100644
index 0000000..f6e5c05
--- /dev/null
+++ b/tests/corpus/chat.make-top-level-from-method.1/expected.main.py
@@ -0,0 +1,20 @@
+def _candidate(a, b, c):
+    return a
+class A:
+    def _calc(self, a):
+        return a
+    def _check(self, a: int, b, c):
+        if a > 0:
+            return self._calc(
+                "a thing or two"
+            )
+        else:
+            return list(
+                self._calc(
+                    _candidate(
+                        a, "string 1", 4
+                    )
+                    for index, item in enumerate(a.the_elements)
+                )
+            )
+
diff --git a/tests/corpus/chat.make-top-level-from-method.1/main.py b/tests/corpus/chat.make-top-level-from-method.1/main.py
new file mode 100644
index 0000000..7f3a560
--- /dev/null
+++ b/tests/corpus/chat.make-top-level-from-method.1/main.py
@@ -0,0 +1,20 @@
+class A:
+    def _calc(self, a):
+        return a
+    def _check(self, a: int, b, c):
+        if a > 0:
+            return self._calc(
+                "a thing or two"
+            )
+        else:
+            return list(
+                self._calc(
+                    self._candidate(
+                        a, "string 1", 4
+                    )
+                    for index, item in enumerate(a.the_elements)
+                )
+            )
+
+    def _candidate(self, a, b, c):
+        return a
diff --git a/tests/corpus/create/chat.xml b/tests/corpus/create/chat.xml
new file mode 100644
index 0000000..a205f7d
--- /dev/null
+++ b/tests/corpus/create/chat.xml
@@ -0,0 +1,18 @@
+<no-train>
+```CEDARScript
+CREATE FILE "1.py"
+WITH CONTENT '''
+@0:def calculate_sum(a, b):
+@1:"""Calculate sum of two numbers.
+@0:
+@1:Args:
+@2:a: First number
+@2:b: Second number
+@0:
+@1:Returns:
+@2:Sum of a and b
+@1:"""
+@1:return a + b
+''';
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/create/expected.1.py b/tests/corpus/create/expected.1.py
new file mode 100644
index 0000000..a64f108
--- /dev/null
+++ b/tests/corpus/create/expected.1.py
@@ -0,0 +1,11 @@
+def calculate_sum(a, b):
+    """Calculate sum of two numbers.
+
+    Args:
+        a: First number
+        b: Second number
+
+    Returns:
+        Sum of a and b
+    """
+    return a + b
\ No newline at end of file
diff --git a/tests/corpus/move.decorated-method/1.py b/tests/corpus/move.decorated-method/1.py
new file mode 100644
index 0000000..77b936c
--- /dev/null
+++ b/tests/corpus/move.decorated-method/1.py
@@ -0,0 +1,24 @@
+class SpyderKernel(IPythonKernel):
+    """Spyder kernel for Jupyter."""
+
+    shell_class = SpyderShell
+    @comm_handler
+    def safe_exec(self, filename):
+        """Safely execute a file using IPKernelApp._exec_file."""
+        self.parent._exec_file(filename)
+
+    @comm_handler
+    def get_fault_text(self, fault_filename, main_id, ignore_ids):
+        """Get fault text from old run."""
+        # Read file
+        try:
+            with open(fault_filename, 'r') as f:
+                fault = f.read()
+        except FileNotFoundError:
+            return
+        return text
+
+    def get_system_threads_id(self):
+        """Return the list of system threads id."""
+        ignore_threads = [
+        ]
diff --git a/tests/corpus/move.decorated-method/chat.xml b/tests/corpus/move.decorated-method/chat.xml
new file mode 100644
index 0000000..e81bef5
--- /dev/null
+++ b/tests/corpus/move.decorated-method/chat.xml
@@ -0,0 +1,20 @@
+<no-train>
+```CEDARScript
+# 1. Move the `get_fault_text` method from the `SpyderKernel` class to be a top-level function
+UPDATE METHOD "SpyderKernel.get_fault_text"
+FROM FILE "1.py"
+MOVE WHOLE
+INSERT BEFORE CLASS "SpyderKernel"
+RELATIVE INDENTATION 0;
+
+# 2. Update the copied function to remove references to `self`
+UPDATE FUNCTION "get_fault_text"
+FROM FILE r"1.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''def get_fault_text\(''' THEN SUB
+r'''def get_fault_text\(self, fault_filename, main_id, ignore_ids\):'''
+r'''def get_fault_text(fault_filename, main_id, ignore_ids):'''
+END;
+
+```
+</no-train>
diff --git a/tests/corpus/move.decorated-method/expected.1.py b/tests/corpus/move.decorated-method/expected.1.py
new file mode 100644
index 0000000..c9e571c
--- /dev/null
+++ b/tests/corpus/move.decorated-method/expected.1.py
@@ -0,0 +1,24 @@
+@comm_handler
+def get_fault_text(fault_filename, main_id, ignore_ids):
+    """Get fault text from old run."""
+    # Read file
+    try:
+        with open(fault_filename, 'r') as f:
+            fault = f.read()
+    except FileNotFoundError:
+        return
+    return text
+class SpyderKernel(IPythonKernel):
+    """Spyder kernel for Jupyter."""
+
+    shell_class = SpyderShell
+    @comm_handler
+    def safe_exec(self, filename):
+        """Safely execute a file using IPKernelApp._exec_file."""
+        self.parent._exec_file(filename)
+
+
+    def get_system_threads_id(self):
+        """Return the list of system threads id."""
+        ignore_threads = [
+        ]
diff --git a/tests/corpus/move.double-decorated-method/1.py b/tests/corpus/move.double-decorated-method/1.py
new file mode 100644
index 0000000..090cc25
--- /dev/null
+++ b/tests/corpus/move.double-decorated-method/1.py
@@ -0,0 +1,28 @@
+import functools
+
+
+class SpyderKernel(IPythonKernel):
+    """Spyder kernel for Jupyter."""
+
+    shell_class = SpyderShell
+    @comm_handler
+    def safe_exec(self, filename):
+        """Safely execute a file using IPKernelApp._exec_file."""
+        self.parent._exec_file(filename)
+
+    @comm_handler
+    @functools.lru_cache(32)
+    def get_fault_text(self, fault_filename, main_id, ignore_ids):
+        """Get fault text from old run."""
+        # Read file
+        try:
+            with open(fault_filename, 'r') as f:
+                fault = f.read()
+        except FileNotFoundError:
+            return
+        return text
+
+    def get_system_threads_id(self):
+        """Return the list of system threads id."""
+        ignore_threads = [
+        ]
diff --git a/tests/corpus/move.double-decorated-method/chat.xml b/tests/corpus/move.double-decorated-method/chat.xml
new file mode 100644
index 0000000..e81bef5
--- /dev/null
+++ b/tests/corpus/move.double-decorated-method/chat.xml
@@ -0,0 +1,20 @@
+<no-train>
+```CEDARScript
+# 1. Move the `get_fault_text` method from the `SpyderKernel` class to be a top-level function
+UPDATE METHOD "SpyderKernel.get_fault_text"
+FROM FILE "1.py"
+MOVE WHOLE
+INSERT BEFORE CLASS "SpyderKernel"
+RELATIVE INDENTATION 0;
+
+# 2. Update the copied function to remove references to `self`
+UPDATE FUNCTION "get_fault_text"
+FROM FILE r"1.py"
+REPLACE WHOLE WITH CASE
+WHEN REGEX r'''def get_fault_text\(''' THEN SUB
+r'''def get_fault_text\(self, fault_filename, main_id, ignore_ids\):'''
+r'''def get_fault_text(fault_filename, main_id, ignore_ids):'''
+END;
+
+```
+</no-train>
diff --git a/tests/corpus/move.double-decorated-method/expected.1.py b/tests/corpus/move.double-decorated-method/expected.1.py
new file mode 100644
index 0000000..6e01d81
--- /dev/null
+++ b/tests/corpus/move.double-decorated-method/expected.1.py
@@ -0,0 +1,28 @@
+import functools
+
+
+@comm_handler
+@functools.lru_cache(32)
+def get_fault_text(fault_filename, main_id, ignore_ids):
+    """Get fault text from old run."""
+    # Read file
+    try:
+        with open(fault_filename, 'r') as f:
+            fault = f.read()
+    except FileNotFoundError:
+        return
+    return text
+class SpyderKernel(IPythonKernel):
+    """Spyder kernel for Jupyter."""
+
+    shell_class = SpyderShell
+    @comm_handler
+    def safe_exec(self, filename):
+        """Safely execute a file using IPKernelApp._exec_file."""
+        self.parent._exec_file(filename)
+
+
+    def get_system_threads_id(self):
+        """Return the list of system threads id."""
+        ignore_threads = [
+        ]
diff --git a/tests/corpus/refactor-benchmark.indentation-size-discovery/autosave.py b/tests/corpus/refactor-benchmark.indentation-size-discovery/autosave.py
new file mode 100644
index 0000000..4084639
--- /dev/null
+++ b/tests/corpus/refactor-benchmark.indentation-size-discovery/autosave.py
@@ -0,0 +1,445 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Autosave components for the Editor plugin and the EditorStack widget
+
+The autosave system regularly checks the contents of all opened files and saves
+a copy in the autosave directory if the contents are different from the
+autosave file (if it exists) or original file (if there is no autosave file).
+
+The mapping between original files and autosave files is stored in the
+variable `name_mapping` and saved in the file `pidNNN.txt` in the autosave
+directory, where `NNN` stands for the pid. This filename is chosen so that
+multiple instances of Spyder can run simultaneously.
+
+File contents are compared using their hash. The variable `file_hashes`
+contains the hash of all files currently open in the editor and all autosave
+files.
+
+On startup, the contents of the autosave directory is checked and if autosave
+files are found, the user is asked whether to recover them;
+see `spyder/plugins/editor/widgets/recover.py`.
+"""
+
+# Standard library imports
+import ast
+import logging
+import os
+import os.path as osp
+import re
+
+# Third party imports
+from qtpy.QtCore import QTimer
+
+# Local imports
+from spyder.config.base import _, get_conf_path, running_under_pytest
+from spyder.plugins.editor.widgets.autosaveerror import AutosaveErrorDialog
+from spyder.plugins.editor.widgets.recover import RecoveryDialog
+from spyder.utils.programs import is_spyder_process
+
+
+logger = logging.getLogger(__name__)
+
+
+class AutosaveForPlugin(object):
+    """
+    Component of editor plugin implementing autosave functionality.
+
+    Attributes:
+        name_mapping (dict): map between names of opened and autosave files.
+        file_hashes (dict): map between file names and hash of their contents.
+            This is used for both files opened in the editor and their
+            corresponding autosave files.
+    """
+
+    # Interval (in ms) between two autosaves
+    DEFAULT_AUTOSAVE_INTERVAL = 60 * 1000
+
+    def __init__(self, editor):
+        """
+        Constructor.
+
+        Autosave is disabled after construction and needs to be enabled
+        explicitly if required.
+
+        Args:
+            editor (Editor): editor plugin.
+        """
+        self.editor = editor
+        self.name_mapping = {}
+        self.file_hashes = {}
+        self.timer = QTimer(self.editor)
+        self.timer.setSingleShot(True)
+        self.timer.timeout.connect(self.do_autosave)
+        self._enabled = False  # Can't use setter here
+        self._interval = self.DEFAULT_AUTOSAVE_INTERVAL
+
+    @property
+    def enabled(self):
+        """
+        Get or set whether autosave component is enabled.
+
+        The setter will start or stop the autosave component if appropriate.
+        """
+        return self._enabled
+
+    @enabled.setter
+    def enabled(self, new_enabled):
+        if new_enabled == self.enabled:
+            return
+        self.stop_autosave_timer()
+        self._enabled = new_enabled
+        self.start_autosave_timer()
+
+    @property
+    def interval(self):
+        """
+        Interval between two autosaves, in milliseconds.
+
+        The setter will perform an autosave if the interval is changed and
+        autosave is enabled.
+        """
+        return self._interval
+
+    @interval.setter
+    def interval(self, new_interval):
+        if new_interval == self.interval:
+            return
+        self.stop_autosave_timer()
+        self._interval = new_interval
+        if self.enabled:
+            self.do_autosave()
+
+    def start_autosave_timer(self):
+        """
+        Start a timer which calls do_autosave() after `self.interval`.
+
+        The autosave timer is only started if autosave is enabled.
+        """
+        if self.enabled:
+            self.timer.start(self.interval)
+
+    def stop_autosave_timer(self):
+        """Stop the autosave timer."""
+        self.timer.stop()
+
+    def do_autosave(self):
+        """Instruct current editorstack to autosave files where necessary."""
+        logger.debug('Autosave triggered')
+        stack = self.editor.get_current_editorstack()
+        stack.autosave.autosave_all()
+        self.start_autosave_timer()
+
+    def get_files_to_recover(self):
+        """
+        Get list of files to recover from pid files in autosave dir.
+
+        This returns a tuple `(files_to_recover, pid_files)`. In this tuple,
+        `files_to_recover` is a list of tuples containing the original file
+        names and the corresponding autosave file names, as recorded in the
+        pid files in the autosave directory. Any files in the autosave
+        directory which are not listed in a pid file, are also included, with
+        the original file name set to `None`. The second entry, `pid_files`,
+        is a list with the names of the pid files.
+        """
+        autosave_dir = get_conf_path('autosave')
+        if not os.access(autosave_dir, os.R_OK):
+            return [], []
+
+        files_to_recover = []
+        files_mentioned = []
+        pid_files = []
+        non_pid_files = []
+
+        # In Python 3, easier to use os.scandir()
+        for name in os.listdir(autosave_dir):
+            full_name = osp.join(autosave_dir, name)
+            match = re.match(r'pid([0-9]*)\.txt\Z', name)
+            if match:
+                pid_files.append(full_name)
+                logger.debug('Reading pid file: {}'.format(full_name))
+                with open(full_name) as pidfile:
+                    txt = pidfile.read()
+                    try:
+                        txt_as_dict = ast.literal_eval(txt)
+                    except (SyntaxError, ValueError):
+                        # Pid file got corrupted, see spyder-ide/spyder#11375
+                        logger.error('Error parsing pid file {}'
+                                     .format(full_name))
+                        logger.error('Contents: {}'.format(repr(txt)))
+                        txt_as_dict = {}
+                    files_mentioned += [autosave for (orig, autosave)
+                                        in txt_as_dict.items()]
+                pid = int(match.group(1))
+                if is_spyder_process(pid):
+                    logger.debug('Ignoring files in {}'.format(full_name))
+                else:
+                    files_to_recover += list(txt_as_dict.items())
+            else:
+                non_pid_files.append(full_name)
+
+        # Add all files not mentioned in any pid file. This can only happen if
+        # the pid file somehow got corrupted.
+        for filename in set(non_pid_files) - set(files_mentioned):
+            files_to_recover.append((None, filename))
+            logger.debug('Added unmentioned file: {}'.format(filename))
+
+        return files_to_recover, pid_files
+
+    def try_recover_from_autosave(self):
+        """
+        Offer to recover files from autosave.
+
+        Read pid files to get a list of files that can possibly be recovered,
+        then ask the user what to do with these files, and finally remove
+        the pid files.
+        """
+        files_to_recover, pidfiles = self.get_files_to_recover()
+        parent = self.editor if running_under_pytest() else self.editor.main
+        dialog = RecoveryDialog(files_to_recover, parent=parent)
+        dialog.exec_if_nonempty()
+        self.recover_files_to_open = dialog.files_to_open[:]
+        for pidfile in pidfiles:
+            try:
+                os.remove(pidfile)
+            except (IOError, OSError):
+                pass
+
+    def register_autosave_for_stack(self, autosave_for_stack):
+        """
+        Register an AutosaveForStack object.
+
+        This replaces the `name_mapping` and `file_hashes` attributes
+        in `autosave_for_stack` with references to the corresponding
+        attributes of `self`, so that all AutosaveForStack objects
+        share the same data.
+        """
+        autosave_for_stack.name_mapping = self.name_mapping
+        autosave_for_stack.file_hashes = self.file_hashes
+
+
+class AutosaveForStack(object):
+    """
+    Component of EditorStack implementing autosave functionality.
+
+    In Spyder, the `name_mapping` and `file_hashes` are set to references to
+    the corresponding variables in `AutosaveForPlugin`.
+
+    Attributes:
+        stack (EditorStack): editor stack this component belongs to.
+        name_mapping (dict): map between names of opened and autosave files.
+        file_hashes (dict): map between file names and hash of their contents.
+            This is used for both files opened in the editor and their
+            corresponding autosave files.
+    """
+
+    def __init__(self, editorstack):
+        """
+        Constructor.
+
+        Args:
+            editorstack (EditorStack): editor stack this component belongs to.
+        """
+        self.stack = editorstack
+        self.name_mapping = {}
+        self.file_hashes = {}
+
+    def create_unique_autosave_filename(self, filename, autosave_dir):
+        """
+        Create unique autosave file name for specified file name.
+
+        The created autosave file name does not yet exist either in
+        `self.name_mapping` or on disk.
+
+        Args:
+            filename (str): original file name
+            autosave_dir (str): directory in which autosave files are stored
+        """
+        basename = osp.basename(filename)
+        autosave_filename = osp.join(autosave_dir, basename)
+        if (autosave_filename in self.name_mapping.values()
+                or osp.exists(autosave_filename)):
+            counter = 0
+            root, ext = osp.splitext(basename)
+            while (autosave_filename in self.name_mapping.values()
+                   or osp.exists(autosave_filename)):
+                counter += 1
+                autosave_basename = '{}-{}{}'.format(root, counter, ext)
+                autosave_filename = osp.join(autosave_dir, autosave_basename)
+        return autosave_filename
+
+    def save_autosave_mapping(self):
+        """
+        Writes current autosave mapping to a pidNNN.txt file.
+
+        This function should be called after updating `self.autosave_mapping`.
+        The NNN in the file name is the pid of the Spyder process. If the
+        current autosave mapping is empty, then delete the file if it exists.
+        """
+        autosave_dir = get_conf_path('autosave')
+        my_pid = os.getpid()
+        pidfile_name = osp.join(autosave_dir, 'pid{}.txt'.format(my_pid))
+        if self.name_mapping:
+            with open(pidfile_name, 'w') as pidfile:
+                pidfile.write(ascii(self.name_mapping))
+        else:
+            try:
+                os.remove(pidfile_name)
+            except (IOError, OSError):
+                pass
+
+    def remove_autosave_file(self, filename):
+        """
+        Remove autosave file for specified file.
+
+        This function also updates `self.name_mapping` and `self.file_hashes`.
+        If there is no autosave file, then the function returns without doing
+        anything.
+        """
+        if filename not in self.name_mapping:
+            return
+        autosave_filename = self.name_mapping[filename]
+        try:
+            os.remove(autosave_filename)
+        except EnvironmentError as error:
+            action = (_('Error while removing autosave file {}')
+                      .format(autosave_filename))
+            msgbox = AutosaveErrorDialog(action, error)
+            msgbox.exec_if_enabled()
+        del self.name_mapping[filename]
+
+        # This is necessary to catch an error when a file is changed externally
+        # but it's left unsaved in Spyder.
+        # Fixes spyder-ide/spyder#19283
+        try:
+            del self.file_hashes[autosave_filename]
+        except KeyError:
+            pass
+
+        self.save_autosave_mapping()
+        logger.debug('Removing autosave file %s', autosave_filename)
+
+    def get_autosave_filename(self, filename):
+        """
+        Get name of autosave file for specified file name.
+
+        This function uses the dict in `self.name_mapping`. If `filename` is
+        in the mapping, then return the corresponding autosave file name.
+        Otherwise, construct a unique file name and update the mapping.
+
+        Args:
+            filename (str): original file name
+        """
+        try:
+            autosave_filename = self.name_mapping[filename]
+        except KeyError:
+            autosave_dir = get_conf_path('autosave')
+            if not osp.isdir(autosave_dir):
+                try:
+                    os.mkdir(autosave_dir)
+                except EnvironmentError as error:
+                    action = _('Error while creating autosave directory')
+                    msgbox = AutosaveErrorDialog(action, error)
+                    msgbox.exec_if_enabled()
+            autosave_filename = self.create_unique_autosave_filename(
+                filename, autosave_dir)
+            self.name_mapping[filename] = autosave_filename
+            self.save_autosave_mapping()
+            logger.debug('New autosave file name')
+        return autosave_filename
+
+    def maybe_autosave(self, index):
+        """
+        Autosave a file if necessary.
+
+        If the file is newly created (and thus not named by the user), do
+        nothing.  If the current contents are the same as the autosave file
+        (if it exists) or the original file (if no autosave filee exists),
+        then do nothing. If the current contents are the same as the file on
+        disc, but the autosave file is different, then remove the autosave
+        file. In all other cases, autosave the file.
+
+        Args:
+            index (int): index into self.stack.data
+        """
+        finfo = self.stack.data[index]
+        if finfo.newly_created:
+            return
+
+        orig_filename = finfo.filename
+        try:
+            orig_hash = self.file_hashes[orig_filename]
+        except KeyError:
+            # This should not happen, but it does: spyder-ide/spyder#11468
+            # In this case, use an impossible value for the hash, so that
+            # contents of buffer are considered different from contents of
+            # original file.
+            logger.debug('KeyError when retrieving hash of %s', orig_filename)
+            orig_hash = None
+
+        new_hash = self.stack.compute_hash(finfo)
+        if orig_filename in self.name_mapping:
+            autosave_filename = self.name_mapping[orig_filename]
+            autosave_hash = self.file_hashes[autosave_filename]
+            if new_hash != autosave_hash:
+                if new_hash == orig_hash:
+                    self.remove_autosave_file(orig_filename)
+                else:
+                    self.autosave(finfo)
+        else:
+            if new_hash != orig_hash:
+                self.autosave(finfo)
+
+    def autosave(self, finfo):
+        """
+        Autosave a file.
+
+        Save a copy in a file with name `self.get_autosave_filename()` and
+        update the cached hash of the autosave file. An error dialog notifies
+        the user of any errors raised when saving.
+
+        Args:
+            fileinfo (FileInfo): file that is to be autosaved.
+        """
+        autosave_filename = self.get_autosave_filename(finfo.filename)
+        logger.debug('Autosaving %s to %s', finfo.filename, autosave_filename)
+        try:
+            self.stack._write_to_file(finfo, autosave_filename)
+            autosave_hash = self.stack.compute_hash(finfo)
+            self.file_hashes[autosave_filename] = autosave_hash
+        except EnvironmentError as error:
+            action = (_('Error while autosaving {} to {}')
+                      .format(finfo.filename, autosave_filename))
+            msgbox = AutosaveErrorDialog(action, error)
+            msgbox.exec_if_enabled()
+
+    def autosave_all(self):
+        """Autosave all opened files where necessary."""
+        for index in range(self.stack.get_stack_count()):
+            self.maybe_autosave(index)
+
+    def file_renamed(self, old_name, new_name):
+        """
+        Update autosave files after a file is renamed.
+
+        Args:
+            old_name (str): name of file before it is renamed
+            new_name (str): name of file after it is renamed
+        """
+        try:
+            old_hash = self.file_hashes[old_name]
+        except KeyError:
+            # This should not happen, but it does: spyder-ide/spyder#12396
+            logger.debug('KeyError when handling rename %s -> %s',
+                         old_name, new_name)
+            old_hash = None
+        self.remove_autosave_file(old_name)
+        if old_hash is not None:
+            del self.file_hashes[old_name]
+            self.file_hashes[new_name] = old_hash
+        index = self.stack.has_filename(new_name)
+        self.maybe_autosave(index)
diff --git a/tests/corpus/refactor-benchmark.indentation-size-discovery/base.py b/tests/corpus/refactor-benchmark.indentation-size-discovery/base.py
new file mode 100644
index 0000000..a934659
--- /dev/null
+++ b/tests/corpus/refactor-benchmark.indentation-size-discovery/base.py
@@ -0,0 +1,373 @@
+import asyncio
+import logging
+import types
+
+from asgiref.sync import async_to_sync, sync_to_async
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured, MiddlewareNotUsed
+from django.core.signals import request_finished
+from django.db import connections, transaction
+from django.urls import get_resolver, set_urlconf
+from django.utils.log import log_response
+from django.utils.module_loading import import_string
+
+from .exception import convert_exception_to_response
+
+logger = logging.getLogger("django.request")
+
+
+class BaseHandler:
+    _view_middleware = None
+    _template_response_middleware = None
+    _exception_middleware = None
+    _middleware_chain = None
+
+    def load_middleware(self, is_async=False):
+        """
+        Populate middleware lists from settings.MIDDLEWARE.
+
+        Must be called after the environment is fixed (see __call__ in subclasses).
+        """
+        self._view_middleware = []
+        self._template_response_middleware = []
+        self._exception_middleware = []
+
+        get_response = self._get_response_async if is_async else self._get_response
+        handler = convert_exception_to_response(get_response)
+        handler_is_async = is_async
+        for middleware_path in reversed(settings.MIDDLEWARE):
+            middleware = import_string(middleware_path)
+            middleware_can_sync = getattr(middleware, "sync_capable", True)
+            middleware_can_async = getattr(middleware, "async_capable", False)
+            if not middleware_can_sync and not middleware_can_async:
+                raise RuntimeError(
+                    "Middleware %s must have at least one of "
+                    "sync_capable/async_capable set to True." % middleware_path
+                )
+            elif not handler_is_async and middleware_can_sync:
+                middleware_is_async = False
+            else:
+                middleware_is_async = middleware_can_async
+            try:
+                # Adapt handler, if needed.
+                adapted_handler = self.adapt_method_mode(
+                    middleware_is_async,
+                    handler,
+                    handler_is_async,
+                    debug=settings.DEBUG,
+                    name="middleware %s" % middleware_path,
+                )
+                mw_instance = middleware(adapted_handler)
+            except MiddlewareNotUsed as exc:
+                if settings.DEBUG:
+                    if str(exc):
+                        logger.debug("MiddlewareNotUsed(%r): %s", middleware_path, exc)
+                    else:
+                        logger.debug("MiddlewareNotUsed: %r", middleware_path)
+                continue
+            else:
+                handler = adapted_handler
+
+            if mw_instance is None:
+                raise ImproperlyConfigured(
+                    "Middleware factory %s returned None." % middleware_path
+                )
+
+            if hasattr(mw_instance, "process_view"):
+                self._view_middleware.insert(
+                    0,
+                    self.adapt_method_mode(is_async, mw_instance.process_view),
+                )
+            if hasattr(mw_instance, "process_template_response"):
+                self._template_response_middleware.append(
+                    self.adapt_method_mode(
+                        is_async, mw_instance.process_template_response
+                    ),
+                )
+            if hasattr(mw_instance, "process_exception"):
+                # The exception-handling stack is still always synchronous for
+                # now, so adapt that way.
+                self._exception_middleware.append(
+                    self.adapt_method_mode(False, mw_instance.process_exception),
+                )
+
+            handler = convert_exception_to_response(mw_instance)
+            handler_is_async = middleware_is_async
+
+        # Adapt the top of the stack, if needed.
+        handler = self.adapt_method_mode(is_async, handler, handler_is_async)
+        # We only assign to this when initialization is complete as it is used
+        # as a flag for initialization being complete.
+        self._middleware_chain = handler
+
+    def adapt_method_mode(
+        self,
+        is_async,
+        method,
+        method_is_async=None,
+        debug=False,
+        name=None,
+    ):
+        """
+        Adapt a method to be in the correct "mode":
+        - If is_async is False:
+          - Synchronous methods are left alone
+          - Asynchronous methods are wrapped with async_to_sync
+        - If is_async is True:
+          - Synchronous methods are wrapped with sync_to_async()
+          - Asynchronous methods are left alone
+        """
+        if method_is_async is None:
+            method_is_async = asyncio.iscoroutinefunction(method)
+        if debug and not name:
+            name = name or "method %s()" % method.__qualname__
+        if is_async:
+            if not method_is_async:
+                if debug:
+                    logger.debug("Synchronous handler adapted for %s.", name)
+                return sync_to_async(method, thread_sensitive=True)
+        elif method_is_async:
+            if debug:
+                logger.debug("Asynchronous handler adapted for %s.", name)
+            return async_to_sync(method)
+        return method
+
+    def get_response(self, request):
+        """Return an HttpResponse object for the given HttpRequest."""
+        # Setup default url resolver for this thread
+        set_urlconf(settings.ROOT_URLCONF)
+        response = self._middleware_chain(request)
+        response._resource_closers.append(request.close)
+        if response.status_code >= 400:
+            log_response(
+                "%s: %s",
+                response.reason_phrase,
+                request.path,
+                response=response,
+                request=request,
+            )
+        return response
+
+    async def get_response_async(self, request):
+        """
+        Asynchronous version of get_response.
+
+        Funneling everything, including WSGI, into a single async
+        get_response() is too slow. Avoid the context switch by using
+        a separate async response path.
+        """
+        # Setup default url resolver for this thread.
+        set_urlconf(settings.ROOT_URLCONF)
+        response = await self._middleware_chain(request)
+        response._resource_closers.append(request.close)
+        if response.status_code >= 400:
+            await sync_to_async(log_response, thread_sensitive=False)(
+                "%s: %s",
+                response.reason_phrase,
+                request.path,
+                response=response,
+                request=request,
+            )
+        return response
+
+    def _get_response(self, request):
+        """
+        Resolve and call the view, then apply view, exception, and
+        template_response middleware. This method is everything that happens
+        inside the request/response middleware.
+        """
+        response = None
+        callback, callback_args, callback_kwargs = self.resolve_request(request)
+
+        # Apply view middleware
+        for middleware_method in self._view_middleware:
+            response = middleware_method(
+                request, callback, callback_args, callback_kwargs
+            )
+            if response:
+                break
+
+        if response is None:
+            wrapped_callback = self.make_view_atomic(callback)
+            # If it is an asynchronous view, run it in a subthread.
+            if asyncio.iscoroutinefunction(wrapped_callback):
+                wrapped_callback = async_to_sync(wrapped_callback)
+            try:
+                response = wrapped_callback(request, *callback_args, **callback_kwargs)
+            except Exception as e:
+                response = self.process_exception_by_middleware(e, request)
+                if response is None:
+                    raise
+
+        # Complain if the view returned None (a common error).
+        self.check_response(response, callback)
+
+        # If the response supports deferred rendering, apply template
+        # response middleware and then render the response
+        if hasattr(response, "render") and callable(response.render):
+            for middleware_method in self._template_response_middleware:
+                response = middleware_method(request, response)
+                # Complain if the template response middleware returned None
+                # (a common error).
+                self.check_response(
+                    response,
+                    middleware_method,
+                    name="%s.process_template_response"
+                    % (middleware_method.__self__.__class__.__name__,),
+                )
+            try:
+                response = response.render()
+            except Exception as e:
+                response = self.process_exception_by_middleware(e, request)
+                if response is None:
+                    raise
+
+        return response
+
+    async def _get_response_async(self, request):
+        """
+        Resolve and call the view, then apply view, exception, and
+        template_response middleware. This method is everything that happens
+        inside the request/response middleware.
+        """
+        response = None
+        callback, callback_args, callback_kwargs = self.resolve_request(request)
+
+        # Apply view middleware.
+        for middleware_method in self._view_middleware:
+            response = await middleware_method(
+                request, callback, callback_args, callback_kwargs
+            )
+            if response:
+                break
+
+        if response is None:
+            wrapped_callback = self.make_view_atomic(callback)
+            # If it is a synchronous view, run it in a subthread
+            if not asyncio.iscoroutinefunction(wrapped_callback):
+                wrapped_callback = sync_to_async(
+                    wrapped_callback, thread_sensitive=True
+                )
+            try:
+                response = await wrapped_callback(
+                    request, *callback_args, **callback_kwargs
+                )
+            except Exception as e:
+                response = await sync_to_async(
+                    self.process_exception_by_middleware,
+                    thread_sensitive=True,
+                )(e, request)
+                if response is None:
+                    raise
+
+        # Complain if the view returned None or an uncalled coroutine.
+        self.check_response(response, callback)
+
+        # If the response supports deferred rendering, apply template
+        # response middleware and then render the response
+        if hasattr(response, "render") and callable(response.render):
+            for middleware_method in self._template_response_middleware:
+                response = await middleware_method(request, response)
+                # Complain if the template response middleware returned None or
+                # an uncalled coroutine.
+                self.check_response(
+                    response,
+                    middleware_method,
+                    name="%s.process_template_response"
+                    % (middleware_method.__self__.__class__.__name__,),
+                )
+            try:
+                if asyncio.iscoroutinefunction(response.render):
+                    response = await response.render()
+                else:
+                    response = await sync_to_async(
+                        response.render, thread_sensitive=True
+                    )()
+            except Exception as e:
+                response = await sync_to_async(
+                    self.process_exception_by_middleware,
+                    thread_sensitive=True,
+                )(e, request)
+                if response is None:
+                    raise
+
+        # Make sure the response is not a coroutine
+        if asyncio.iscoroutine(response):
+            raise RuntimeError("Response is still a coroutine.")
+        return response
+
+    def resolve_request(self, request):
+        """
+        Retrieve/set the urlconf for the request. Return the view resolved,
+        with its args and kwargs.
+        """
+        # Work out the resolver.
+        if hasattr(request, "urlconf"):
+            urlconf = request.urlconf
+            set_urlconf(urlconf)
+            resolver = get_resolver(urlconf)
+        else:
+            resolver = get_resolver()
+        # Resolve the view, and assign the match object back to the request.
+        resolver_match = resolver.resolve(request.path_info)
+        request.resolver_match = resolver_match
+        return resolver_match
+
+    def check_response(self, response, callback, name=None):
+        """
+        Raise an error if the view returned None or an uncalled coroutine.
+        """
+        if not (response is None or asyncio.iscoroutine(response)):
+            return
+        if not name:
+            if isinstance(callback, types.FunctionType):  # FBV
+                name = "The view %s.%s" % (callback.__module__, callback.__name__)
+            else:  # CBV
+                name = "The view %s.%s.__call__" % (
+                    callback.__module__,
+                    callback.__class__.__name__,
+                )
+        if response is None:
+            raise ValueError(
+                "%s didn't return an HttpResponse object. It returned None "
+                "instead." % name
+            )
+        elif asyncio.iscoroutine(response):
+            raise ValueError(
+                "%s didn't return an HttpResponse object. It returned an "
+                "unawaited coroutine instead. You may need to add an 'await' "
+                "into your view." % name
+            )
+
+    # Other utility methods.
+
+    def make_view_atomic(self, view):
+        non_atomic_requests = getattr(view, "_non_atomic_requests", set())
+        for alias, settings_dict in connections.settings.items():
+            if settings_dict["ATOMIC_REQUESTS"] and alias not in non_atomic_requests:
+                if asyncio.iscoroutinefunction(view):
+                    raise RuntimeError(
+                        "You cannot use ATOMIC_REQUESTS with async views."
+                    )
+                view = transaction.atomic(using=alias)(view)
+        return view
+
+    def process_exception_by_middleware(self, exception, request):
+        """
+        Pass the exception to the exception middleware. If no middleware
+        return a response for this exception, return None.
+        """
+        for middleware_method in self._exception_middleware:
+            response = middleware_method(request, exception)
+            if response:
+                return response
+        return None
+
+
+def reset_urlconf(sender, **kwargs):
+    """Reset the URLconf after each request is finished."""
+    set_urlconf(None)
+
+
+request_finished.connect(reset_urlconf)
diff --git a/tests/corpus/refactor-benchmark.indentation-size-discovery/chat.xml b/tests/corpus/refactor-benchmark.indentation-size-discovery/chat.xml
new file mode 100644
index 0000000..10b3956
--- /dev/null
+++ b/tests/corpus/refactor-benchmark.indentation-size-discovery/chat.xml
@@ -0,0 +1,26 @@
+<no-train>
+```CEDARScript
+
+# Try CST-based indentation discovery
+# Move the `__get_brackets` method from the `CodeEditor` class, placing it at the top level, just before the line where its class starts
+UPDATE CLASS "CodeEditor"
+FROM FILE "codeeditor.py"
+MOVE METHOD "__get_brackets"
+INSERT BEFORE CLASS "CodeEditor"
+RELATIVE INDENTATION 0;
+
+# 1. Move the method to become a top-level function.
+UPDATE CLASS "AutosaveForPlugin"
+FROM FILE "autosave.py"
+MOVE METHOD "get_files_to_recover"
+INSERT BEFORE CLASS "AutosaveForPlugin"
+RELATIVE INDENTATION 0;
+
+# 1. Move the `adapt_method_mode` method from the `BaseHandler` class, placing it at the top level, just before the line where its class starts
+UPDATE CLASS "BaseHandler"
+FROM FILE "base.py"
+MOVE METHOD "adapt_method_mode"
+INSERT BEFORE CLASS "BaseHandler"
+RELATIVE INDENTATION 0;
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/refactor-benchmark.indentation-size-discovery/codeeditor.py b/tests/corpus/refactor-benchmark.indentation-size-discovery/codeeditor.py
new file mode 100644
index 0000000..59de4ca
--- /dev/null
+++ b/tests/corpus/refactor-benchmark.indentation-size-discovery/codeeditor.py
@@ -0,0 +1,4431 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Editor widget based on QtGui.QPlainTextEdit
+"""
+
+# TODO: Try to separate this module from spyder to create a self
+#       consistent editor module (Qt source code and shell widgets library)
+
+# pylint: disable=C0103
+# pylint: disable=R0903
+# pylint: disable=R0911
+# pylint: disable=R0201
+
+# Standard library imports
+from unicodedata import category
+import logging
+import os
+import os.path as osp
+import re
+import sre_constants
+import sys
+import textwrap
+
+# Third party imports
+from IPython.core.inputtransformer2 import TransformerManager
+from packaging.version import parse
+from qtpy import QT_VERSION
+from qtpy.compat import to_qvariant
+from qtpy.QtCore import (
+    QEvent, QRegularExpression, Qt, QTimer, QUrl, Signal, Slot)
+from qtpy.QtGui import (QColor, QCursor, QFont, QKeySequence, QPaintEvent,
+                        QPainter, QMouseEvent, QTextCursor, QDesktopServices,
+                        QKeyEvent, QTextDocument, QTextFormat, QTextOption,
+                        QTextCharFormat, QTextLayout)
+from qtpy.QtWidgets import (QApplication, QMenu, QMessageBox, QSplitter,
+                            QScrollBar)
+from spyder_kernels.utils.dochelpers import getobj
+
+
+# Local imports
+from spyder.config.base import _, running_under_pytest
+from spyder.plugins.editor.api.decoration import TextDecoration
+from spyder.plugins.editor.api.panel import Panel
+from spyder.plugins.editor.extensions import (CloseBracketsExtension,
+                                              CloseQuotesExtension,
+                                              DocstringWriterExtension,
+                                              QMenuOnlyForEnter,
+                                              EditorExtensionsManager,
+                                              SnippetsExtension)
+from spyder.plugins.completion.api import DiagnosticSeverity
+from spyder.plugins.editor.panels import (
+    ClassFunctionDropdown, EdgeLine, FoldingPanel, IndentationGuide,
+    LineNumberArea, PanelsManager, ScrollFlagArea)
+from spyder.plugins.editor.utils.editor import (TextHelper, BlockUserData,
+                                                get_file_language)
+from spyder.plugins.editor.utils.kill_ring import QtKillRing
+from spyder.plugins.editor.utils.languages import ALL_LANGUAGES, CELL_LANGUAGES
+from spyder.plugins.editor.widgets.gotoline import GoToLineDialog
+from spyder.plugins.editor.widgets.base import TextEditBaseWidget
+from spyder.plugins.editor.widgets.codeeditor.lsp_mixin import LSPMixin
+from spyder.plugins.outlineexplorer.api import (OutlineExplorerData as OED,
+                                                is_cell_header)
+from spyder.py3compat import to_text_string, is_string
+from spyder.utils import encoding, sourcecode
+from spyder.utils.clipboard_helper import CLIPBOARD_HELPER
+from spyder.utils.icon_manager import ima
+from spyder.utils import syntaxhighlighters as sh
+from spyder.utils.palette import SpyderPalette, QStylePalette
+from spyder.utils.qthelpers import (add_actions, create_action, file_uri,
+                                    mimedata2url, start_file)
+from spyder.utils.vcs import get_git_remotes, remote_to_url
+from spyder.utils.qstringhelpers import qstring_length
+
+
+try:
+    import nbformat as nbformat
+    from nbconvert import PythonExporter as nbexporter
+except Exception:
+    nbformat = None  # analysis:ignore
+
+logger = logging.getLogger(__name__)
+
+
+class CodeEditor(LSPMixin, TextEditBaseWidget):
+    """Source Code Editor Widget based exclusively on Qt"""
+
+    CONF_SECTION = 'editor'
+
+    LANGUAGES = {
+        'Python': (sh.PythonSH, '#'),
+        'IPython': (sh.IPythonSH, '#'),
+        'Cython': (sh.CythonSH, '#'),
+        'Fortran77': (sh.Fortran77SH, 'c'),
+        'Fortran': (sh.FortranSH, '!'),
+        'Idl': (sh.IdlSH, ';'),
+        'Diff': (sh.DiffSH, ''),
+        'GetText': (sh.GetTextSH, '#'),
+        'Nsis': (sh.NsisSH, '#'),
+        'Html': (sh.HtmlSH, ''),
+        'Yaml': (sh.YamlSH, '#'),
+        'Cpp': (sh.CppSH, '//'),
+        'OpenCL': (sh.OpenCLSH, '//'),
+        'Enaml': (sh.EnamlSH, '#'),
+        'Markdown': (sh.MarkdownSH, '#'),
+        # Every other language
+        'None': (sh.TextSH, ''),
+    }
+
+    TAB_ALWAYS_INDENTS = (
+        'py', 'pyw', 'python', 'ipy', 'c', 'cpp', 'cl', 'h', 'pyt', 'pyi'
+    )
+
+    # Timeout to update decorations (through a QTimer) when a position
+    # changed is detected in the vertical scrollbar or when releasing
+    # the up/down arrow keys.
+    UPDATE_DECORATIONS_TIMEOUT = 500  # milliseconds
+
+    # Custom signal to be emitted upon completion of the editor's paintEvent
+    painted = Signal(QPaintEvent)
+
+    # To have these attrs when early viewportEvent's are triggered
+    edge_line = None
+    indent_guides = None
+
+    sig_filename_changed = Signal(str)
+    sig_bookmarks_changed = Signal()
+    go_to_definition = Signal(str, int, int)
+    sig_show_object_info = Signal(bool)
+    sig_cursor_position_changed = Signal(int, int)
+    sig_new_file = Signal(str)
+    sig_refresh_formatting = Signal(bool)
+
+    #: Signal emitted when the editor loses focus
+    sig_focus_changed = Signal()
+
+    #: Signal emitted when a key is pressed
+    sig_key_pressed = Signal(QKeyEvent)
+
+    #: Signal emitted when a key is released
+    sig_key_released = Signal(QKeyEvent)
+
+    #: Signal emitted when the alt key is pressed and the left button of the
+    #  mouse is clicked
+    sig_alt_left_mouse_pressed = Signal(QMouseEvent)
+
+    #: Signal emitted when the alt key is pressed and the cursor moves over
+    #  the editor
+    sig_alt_mouse_moved = Signal(QMouseEvent)
+
+    #: Signal emitted when the cursor leaves the editor
+    sig_leave_out = Signal()
+
+    #: Signal emitted when the flags need to be updated in the scrollflagarea
+    sig_flags_changed = Signal()
+
+    #: Signal emitted when the syntax color theme of the editor.
+    sig_theme_colors_changed = Signal(dict)
+
+    #: Signal emitted when a new text is set on the widget
+    new_text_set = Signal()
+
+    # Used for testing. When the mouse moves with Ctrl/Cmd pressed and
+    # a URI is found, this signal is emitted
+    sig_uri_found = Signal(str)
+
+    sig_file_uri_preprocessed = Signal(str)
+    """
+    This signal is emitted when the go to uri for a file has been
+    preprocessed.
+
+    Parameters
+    ----------
+    fpath: str
+        The preprocessed file path.
+    """
+
+    # Signal with the info about the current completion item documentation
+    # str: object name
+    # str: object signature/documentation
+    # bool: force showing the info
+    sig_show_completion_object_info = Signal(str, str, bool)
+
+    # Used to indicate if text was inserted into the editor
+    sig_text_was_inserted = Signal()
+
+    # Used to indicate that text will be inserted into the editor
+    sig_will_insert_text = Signal(str)
+
+    # Used to indicate that a text selection will be removed
+    sig_will_remove_selection = Signal(tuple, tuple)
+
+    # Used to indicate that text will be pasted
+    sig_will_paste_text = Signal(str)
+
+    # Used to indicate that an undo operation will take place
+    sig_undo = Signal()
+
+    # Used to indicate that an undo operation will take place
+    sig_redo = Signal()
+
+    # Used to signal font change
+    sig_font_changed = Signal()
+
+    # Used to request saving a file
+    sig_save_requested = Signal()
+
+    def __init__(self, parent=None):
+        super().__init__(parent=parent)
+
+        self.setFocusPolicy(Qt.StrongFocus)
+
+        # Projects
+        self.current_project_path = None
+
+        # Caret (text cursor)
+        self.setCursorWidth(self.get_conf('cursor/width', section='main'))
+
+        self.text_helper = TextHelper(self)
+
+        self._panels = PanelsManager(self)
+
+        # Mouse moving timer / Hover hints handling
+        # See: mouseMoveEvent
+        self.tooltip_widget.sig_help_requested.connect(
+            self.show_object_info)
+        self.tooltip_widget.sig_completion_help_requested.connect(
+            self.show_completion_object_info)
+        self._last_point = None
+        self._last_hover_word = None
+        self._last_hover_cursor = None
+        self._timer_mouse_moving = QTimer(self)
+        self._timer_mouse_moving.setInterval(350)
+        self._timer_mouse_moving.setSingleShot(True)
+        self._timer_mouse_moving.timeout.connect(self._handle_hover)
+
+        # Typing keys / handling for on the fly completions
+        self._last_key_pressed_text = ''
+        self._last_pressed_key = None
+
+        # Handle completions hints
+        self._completions_hint_idle = False
+        self._timer_completions_hint = QTimer(self)
+        self._timer_completions_hint.setSingleShot(True)
+        self._timer_completions_hint.timeout.connect(
+            self._set_completions_hint_idle)
+        self.completion_widget.sig_completion_hint.connect(
+            self.show_hint_for_completion)
+
+        # Goto uri
+        self._last_hover_pattern_key = None
+        self._last_hover_pattern_text = None
+
+        # 79-col edge line
+        self.edge_line = self.panels.register(EdgeLine(),
+                                              Panel.Position.FLOATING)
+
+        # indent guides
+        self.indent_guides = self.panels.register(IndentationGuide(),
+                                                  Panel.Position.FLOATING)
+        # Blanks enabled
+        self.blanks_enabled = False
+
+        # Underline errors and warnings
+        self.underline_errors_enabled = False
+
+        # Scrolling past the end of the document
+        self.scrollpastend_enabled = False
+
+        self.background = QColor('white')
+
+        # Folding
+        self.panels.register(FoldingPanel())
+
+        # Line number area management
+        self.linenumberarea = self.panels.register(LineNumberArea())
+
+        # Class and Method/Function Dropdowns
+        self.classfuncdropdown = self.panels.register(
+            ClassFunctionDropdown(),
+            Panel.Position.TOP,
+        )
+
+        # Colors to be defined in _apply_highlighter_color_scheme()
+        # Currentcell color and current line color are defined in base.py
+        self.occurrence_color = None
+        self.ctrl_click_color = None
+        self.sideareas_color = None
+        self.matched_p_color = None
+        self.unmatched_p_color = None
+        self.normal_color = None
+        self.comment_color = None
+
+        # --- Syntax highlight entrypoint ---
+        #
+        # - if set, self.highlighter is responsible for
+        #   - coloring raw text data inside editor on load
+        #   - coloring text data when editor is cloned
+        #   - updating document highlight on line edits
+        #   - providing color palette (scheme) for the editor
+        #   - providing data for Outliner
+        # - self.highlighter is not responsible for
+        #   - background highlight for current line
+        #   - background highlight for search / current line occurrences
+
+        self.highlighter_class = sh.TextSH
+        self.highlighter = None
+        ccs = 'Spyder'
+        if ccs not in sh.COLOR_SCHEME_NAMES:
+            ccs = sh.COLOR_SCHEME_NAMES[0]
+        self.color_scheme = ccs
+
+        self.highlight_current_line_enabled = False
+
+        # Vertical scrollbar
+        # This is required to avoid a "RuntimeError: no access to protected
+        # functions or signals for objects not created from Python" in
+        # Linux Ubuntu. See spyder-ide/spyder#5215.
+        self.setVerticalScrollBar(QScrollBar())
+
+        # Highlights and flag colors
+        self.warning_color = SpyderPalette.COLOR_WARN_2
+        self.error_color = SpyderPalette.COLOR_ERROR_1
+        self.todo_color = SpyderPalette.GROUP_9
+        self.breakpoint_color = SpyderPalette.ICON_3
+        self.occurrence_color = QColor(SpyderPalette.GROUP_2).lighter(160)
+        self.found_results_color = QColor(SpyderPalette.COLOR_OCCURRENCE_4)
+
+        # Scrollbar flag area
+        self.scrollflagarea = self.panels.register(ScrollFlagArea(),
+                                                   Panel.Position.RIGHT)
+        self.panels.refresh()
+
+        self.document_id = id(self)
+
+        # Indicate occurrences of the selected word
+        self.cursorPositionChanged.connect(self.__cursor_position_changed)
+        self.__find_first_pos = None
+        self.__find_args = {}
+
+        self.language = None
+        self.supported_language = False
+        self.supported_cell_language = False
+        self.comment_string = None
+        self._kill_ring = QtKillRing(self)
+
+        # Block user data
+        self.blockCountChanged.connect(self.update_bookmarks)
+
+        # Highlight using Pygments highlighter timer
+        # ---------------------------------------------------------------------
+        # For files that use the PygmentsSH we parse the full file inside
+        # the highlighter in order to generate the correct coloring.
+        self.timer_syntax_highlight = QTimer(self)
+        self.timer_syntax_highlight.setSingleShot(True)
+        self.timer_syntax_highlight.timeout.connect(
+            self.run_pygments_highlighter)
+
+        # Mark occurrences timer
+        self.occurrence_highlighting = None
+        self.occurrence_timer = QTimer(self)
+        self.occurrence_timer.setSingleShot(True)
+        self.occurrence_timer.setInterval(1500)
+        self.occurrence_timer.timeout.connect(self.mark_occurrences)
+        self.occurrences = []
+
+        # Update decorations
+        self.update_decorations_timer = QTimer(self)
+        self.update_decorations_timer.setSingleShot(True)
+        self.update_decorations_timer.setInterval(
+            self.UPDATE_DECORATIONS_TIMEOUT)
+        self.update_decorations_timer.timeout.connect(
+            self.update_decorations)
+        self.verticalScrollBar().valueChanged.connect(
+            lambda value: self.update_decorations_timer.start())
+
+        # QTextEdit + LSPMixin
+        self.textChanged.connect(self._schedule_document_did_change)
+
+        # Mark found results
+        self.textChanged.connect(self.__text_has_changed)
+        self.found_results = []
+
+        # Docstring
+        self.writer_docstring = DocstringWriterExtension(self)
+
+        # Context menu
+        self.gotodef_action = None
+        self.setup_context_menu()
+
+        # Tab key behavior
+        self.tab_indents = None
+        self.tab_mode = True  # see CodeEditor.set_tab_mode
+
+        # Intelligent backspace mode
+        self.intelligent_backspace = True
+
+        # Automatic (on the fly) completions
+        self.automatic_completions = True
+        self.automatic_completions_after_chars = 3
+
+        # Completions hint
+        self.completions_hint = True
+        self.completions_hint_after_ms = 500
+
+        self.close_parentheses_enabled = True
+        self.close_quotes_enabled = False
+        self.add_colons_enabled = True
+        self.auto_unindent_enabled = True
+
+        # Mouse tracking
+        self.setMouseTracking(True)
+        self.__cursor_changed = False
+        self._mouse_left_button_pressed = False
+        self.ctrl_click_color = QColor(Qt.blue)
+
+        self._bookmarks_blocks = {}
+        self.bookmarks = []
+
+        # Keyboard shortcuts
+        self.shortcuts = self.create_shortcuts()
+
+        # Paint event
+        self.__visible_blocks = []  # Visible blocks, update with repaint
+        self.painted.connect(self._draw_editor_cell_divider)
+
+        # Line stripping
+        self.last_change_position = None
+        self.last_position = None
+        self.last_auto_indent = None
+        self.skip_rstrip = False
+        self.strip_trailing_spaces_on_modify = True
+
+        # Hover hints
+        self.hover_hints_enabled = None
+
+        # Editor Extensions
+        self.editor_extensions = EditorExtensionsManager(self)
+        self.editor_extensions.add(CloseQuotesExtension())
+        self.editor_extensions.add(SnippetsExtension())
+        self.editor_extensions.add(CloseBracketsExtension())
+
+        # Some events should not be triggered during undo/redo
+        # such as line stripping
+        self.is_undoing = False
+        self.is_redoing = False
+
+        # Timer to Avoid too many calls to rehighlight.
+        self._rehighlight_timer = QTimer(self)
+        self._rehighlight_timer.setSingleShot(True)
+        self._rehighlight_timer.setInterval(150)
+
+    # ---- Hover/Hints
+    # -------------------------------------------------------------------------
+    def _should_display_hover(self, point):
+        """Check if a hover hint should be displayed:"""
+        if not self._mouse_left_button_pressed:
+            return (self.hover_hints_enabled and point
+                    and self.get_word_at(point))
+
+    def _handle_hover(self):
+        """Handle hover hint trigger after delay."""
+        self._timer_mouse_moving.stop()
+        pos = self._last_point
+
+        # These are textual characters but should not trigger a completion
+        # FIXME: update per language
+        ignore_chars = ['(', ')', '.']
+
+        if self._should_display_hover(pos):
+            key, pattern_text, cursor = self.get_pattern_at(pos)
+            text = self.get_word_at(pos)
+            if pattern_text:
+                ctrl_text = 'Cmd' if sys.platform == "darwin" else 'Ctrl'
+                if key in ['file']:
+                    hint_text = ctrl_text + ' + ' + _('click to open file')
+                elif key in ['mail']:
+                    hint_text = ctrl_text + ' + ' + _('click to send email')
+                elif key in ['url']:
+                    hint_text = ctrl_text + ' + ' + _('click to open url')
+                else:
+                    hint_text = ctrl_text + ' + ' + _('click to open')
+
+                hint_text = '<span>&nbsp;{}&nbsp;</span>'.format(hint_text)
+
+                self.show_tooltip(text=hint_text, at_point=pos)
+                return
+
+            cursor = self.cursorForPosition(pos)
+            cursor_offset = cursor.position()
+            line, col = cursor.blockNumber(), cursor.columnNumber()
+            self._last_point = pos
+            if text and self._last_hover_word != text:
+                if all(char not in text for char in ignore_chars):
+                    self._last_hover_word = text
+                    self.request_hover(line, col, cursor_offset)
+                else:
+                    self.hide_tooltip()
+        elif not self.is_completion_widget_visible():
+            self.hide_tooltip()
+
+    def blockuserdata_list(self):
+        """Get the list of all user data in document."""
+        block = self.document().firstBlock()
+        while block.isValid():
+            data = block.userData()
+            if data:
+                yield data
+            block = block.next()
+
+    def outlineexplorer_data_list(self):
+        """Get the list of all user data in document."""
+        for data in self.blockuserdata_list():
+            if data.oedata:
+                yield data.oedata
+
+    # ---- Keyboard Shortcuts
+    # -------------------------------------------------------------------------
+    def create_cursor_callback(self, attr):
+        """Make a callback for cursor move event type, (e.g. "Start")"""
+        def cursor_move_event():
+            cursor = self.textCursor()
+            move_type = getattr(QTextCursor, attr)
+            cursor.movePosition(move_type)
+            self.setTextCursor(cursor)
+        return cursor_move_event
+
+    def create_shortcuts(self):
+        """Create the local shortcuts for the CodeEditor."""
+        shortcut_context_name_callbacks = (
+            ('editor', 'code completion', self.do_completion),
+            ('editor', 'duplicate line down', self.duplicate_line_down),
+            ('editor', 'duplicate line up', self.duplicate_line_up),
+            ('editor', 'delete line', self.delete_line),
+            ('editor', 'move line up', self.move_line_up),
+            ('editor', 'move line down', self.move_line_down),
+            ('editor', 'go to new line', self.go_to_new_line),
+            ('editor', 'go to definition', self.go_to_definition_from_cursor),
+            ('editor', 'toggle comment', self.toggle_comment),
+            ('editor', 'blockcomment', self.blockcomment),
+            ('editor', 'create_new_cell', self.create_new_cell),
+            ('editor', 'unblockcomment', self.unblockcomment),
+            ('editor', 'transform to uppercase', self.transform_to_uppercase),
+            ('editor', 'transform to lowercase', self.transform_to_lowercase),
+            ('editor', 'indent', lambda: self.indent(force=True)),
+            ('editor', 'unindent', lambda: self.unindent(force=True)),
+            ('editor', 'start of line',
+             self.create_cursor_callback('StartOfLine')),
+            ('editor', 'end of line',
+             self.create_cursor_callback('EndOfLine')),
+            ('editor', 'previous line', self.create_cursor_callback('Up')),
+            ('editor', 'next line', self.create_cursor_callback('Down')),
+            ('editor', 'previous char', self.create_cursor_callback('Left')),
+            ('editor', 'next char', self.create_cursor_callback('Right')),
+            ('editor', 'previous word',
+             self.create_cursor_callback('PreviousWord')),
+            ('editor', 'next word', self.create_cursor_callback('NextWord')),
+            ('editor', 'kill to line end', self.kill_line_end),
+            ('editor', 'kill to line start', self.kill_line_start),
+            ('editor', 'yank', self._kill_ring.yank),
+            ('editor', 'rotate kill ring', self._kill_ring.rotate),
+            ('editor', 'kill previous word', self.kill_prev_word),
+            ('editor', 'kill next word', self.kill_next_word),
+            ('editor', 'start of document',
+             self.create_cursor_callback('Start')),
+            ('editor', 'end of document',
+             self.create_cursor_callback('End')),
+            ('editor', 'undo', self.undo),
+            ('editor', 'redo', self.redo),
+            ('editor', 'cut', self.cut),
+            ('editor', 'copy', self.copy),
+            ('editor', 'paste', self.paste),
+            ('editor', 'delete', self.delete),
+            ('editor', 'select all', self.selectAll),
+            ('editor', 'docstring',
+             self.writer_docstring.write_docstring_for_shortcut),
+            ('editor', 'autoformatting', self.format_document_or_range),
+            ('array_builder', 'enter array inline', self.enter_array_inline),
+            ('array_builder', 'enter array table', self.enter_array_table),
+            ('editor', 'scroll line down', self.scroll_line_down),
+            ('editor', 'scroll line up', self.scroll_line_up)
+            )
+
+        shortcuts = []
+        for context, name, callback in shortcut_context_name_callbacks:
+            shortcuts.append(
+                self.config_shortcut(
+                    callback, context=context, name=name, parent=self))
+        return shortcuts
+
+    def get_shortcut_data(self):
+        """
+        Returns shortcut data, a list of tuples (shortcut, text, default)
+        shortcut (QShortcut or QAction instance)
+        text (string): action/shortcut description
+        default (string): default key sequence
+        """
+        return [sc.data for sc in self.shortcuts]
+
+    def closeEvent(self, event):
+        if isinstance(self.highlighter, sh.PygmentsSH):
+            self.highlighter.stop()
+        self.update_folding_thread.quit()
+        self.update_folding_thread.wait()
+        self.update_diagnostics_thread.quit()
+        self.update_diagnostics_thread.wait()
+        TextEditBaseWidget.closeEvent(self, event)
+
+    def get_document_id(self):
+        return self.document_id
+
+    def set_as_clone(self, editor):
+        """Set as clone editor"""
+        self.setDocument(editor.document())
+        self.document_id = editor.get_document_id()
+        self.highlighter = editor.highlighter
+        self._rehighlight_timer.timeout.connect(
+            self.highlighter.rehighlight)
+        self.eol_chars = editor.eol_chars
+        self._apply_highlighter_color_scheme()
+        self.highlighter.sig_font_changed.connect(self.sync_font)
+
+    # ---- Widget setup and options
+    # -------------------------------------------------------------------------
+    def toggle_wrap_mode(self, enable):
+        """Enable/disable wrap mode"""
+        self.set_wrap_mode('word' if enable else None)
+
+    def toggle_line_numbers(self, linenumbers=True, markers=False):
+        """Enable/disable line numbers."""
+        self.linenumberarea.setup_margins(linenumbers, markers)
+
+    @property
+    def panels(self):
+        """
+        Returns a reference to the
+        :class:`spyder.widgets.panels.managers.PanelsManager`
+        used to manage the collection of installed panels
+        """
+        return self._panels
+
+    def setup_editor(self,
+                     linenumbers=True,
+                     language=None,
+                     markers=False,
+                     font=None,
+                     color_scheme=None,
+                     wrap=False,
+                     tab_mode=True,
+                     strip_mode=False,
+                     intelligent_backspace=True,
+                     automatic_completions=True,
+                     automatic_completions_after_chars=3,
+                     completions_hint=True,
+                     completions_hint_after_ms=500,
+                     hover_hints=True,
+                     code_snippets=True,
+                     highlight_current_line=True,
+                     highlight_current_cell=True,
+                     occurrence_highlighting=True,
+                     scrollflagarea=True,
+                     edge_line=True,
+                     edge_line_columns=(79,),
+                     show_blanks=False,
+                     underline_errors=False,
+                     close_parentheses=True,
+                     close_quotes=False,
+                     add_colons=True,
+                     auto_unindent=True,
+                     indent_chars=" "*4,
+                     tab_stop_width_spaces=4,
+                     cloned_from=None,
+                     filename=None,
+                     occurrence_timeout=1500,
+                     show_class_func_dropdown=False,
+                     indent_guides=False,
+                     scroll_past_end=False,
+                     folding=True,
+                     remove_trailing_spaces=False,
+                     remove_trailing_newlines=False,
+                     add_newline=False,
+                     format_on_save=False):
+        """
+        Set-up configuration for the CodeEditor instance.
+
+        Usually the parameters here are related with a configurable preference
+        in the Preference Dialog and Editor configurations:
+
+        linenumbers: Enable/Disable line number panel. Default True.
+        language: Set editor language for example python. Default None.
+        markers: Enable/Disable markers panel. Used to show elements like
+            Code Analysis. Default False.
+        font: Base font for the Editor to use. Default None.
+        color_scheme: Initial color scheme for the Editor to use. Default None.
+        wrap: Enable/Disable line wrap. Default False.
+        tab_mode: Enable/Disable using Tab as delimiter between word,
+            Default True.
+        strip_mode: strip_mode: Enable/Disable striping trailing spaces when
+            modifying the file. Default False.
+        intelligent_backspace: Enable/Disable automatically unindenting
+            inserted text (unindenting happens if the leading text length of
+            the line isn't module of the length of indentation chars being use)
+            Default True.
+        automatic_completions: Enable/Disable automatic completions.
+            The behavior of the trigger of this the completions can be
+            established with the two following kwargs. Default True.
+        automatic_completions_after_chars: Number of charts to type to trigger
+            an automatic completion. Default 3.
+        completions_hint: Enable/Disable documentation hints for completions.
+            Default True.
+        completions_hint_after_ms: Number of milliseconds over a completion
+            item to show the documentation hint. Default 500.
+        hover_hints: Enable/Disable documentation hover hints. Default True.
+        code_snippets: Enable/Disable code snippets completions. Default True.
+        highlight_current_line: Enable/Disable current line highlighting.
+            Default True.
+        highlight_current_cell: Enable/Disable current cell highlighting.
+            Default True.
+        occurrence_highlighting: Enable/Disable highlighting of current word
+            occurrence in the file. Default True.
+        scrollflagarea : Enable/Disable flag area that shows at the left of
+            the scroll bar. Default True.
+        edge_line: Enable/Disable vertical line to show max number of
+            characters per line. Customizable number of columns in the
+            following kwarg. Default True.
+        edge_line_columns: Number of columns/characters where the editor
+            horizontal edge line will show. Default (79,).
+        show_blanks: Enable/Disable blanks highlighting. Default False.
+        underline_errors: Enable/Disable showing and underline to highlight
+            errors. Default False.
+        close_parentheses: Enable/Disable automatic parentheses closing
+            insertion. Default True.
+        close_quotes: Enable/Disable automatic closing of quotes.
+            Default False.
+        add_colons: Enable/Disable automatic addition of colons. Default True.
+        auto_unindent: Enable/Disable automatically unindentation before else,
+            elif, finally or except statements. Default True.
+        indent_chars: Characters to use for indentation. Default " "*4.
+        tab_stop_width_spaces: Enable/Disable using tabs for indentation.
+            Default 4.
+        cloned_from: Editor instance used as template to instantiate this
+            CodeEditor instance. Default None.
+        filename: Initial filename to show. Default None.
+        occurrence_timeout : Timeout in milliseconds to start highlighting
+            matches/occurrences for the current word under the cursor.
+            Default 1500 ms.
+        show_class_func_dropdown: Enable/Disable a Matlab like widget to show
+            classes and functions available in the current file. Default False.
+        indent_guides: Enable/Disable highlighting of code indentation.
+            Default False.
+        scroll_past_end: Enable/Disable possibility to scroll file passed
+            its end. Default False.
+        folding: Enable/Disable code folding. Default True.
+        remove_trailing_spaces: Remove trailing whitespaces on lines.
+            Default False.
+        remove_trailing_newlines: Remove extra lines at the end of the file.
+            Default False.
+        add_newline: Add a newline at the end of the file if there is not one.
+            Default False.
+        format_on_save: Autoformat file automatically when saving.
+            Default False.
+        """
+
+        self.set_close_parentheses_enabled(close_parentheses)
+        self.set_close_quotes_enabled(close_quotes)
+        self.set_add_colons_enabled(add_colons)
+        self.set_auto_unindent_enabled(auto_unindent)
+        self.set_indent_chars(indent_chars)
+
+        # Show/hide folding panel depending on parameter
+        self.toggle_code_folding(folding)
+
+        # Scrollbar flag area
+        self.scrollflagarea.set_enabled(scrollflagarea)
+
+        # Edge line
+        self.edge_line.set_enabled(edge_line)
+        self.edge_line.set_columns(edge_line_columns)
+
+        # Indent guides
+        self.toggle_identation_guides(indent_guides)
+        if self.indent_chars == '\t':
+            self.indent_guides.set_indentation_width(
+                tab_stop_width_spaces)
+        else:
+            self.indent_guides.set_indentation_width(len(self.indent_chars))
+
+        # Blanks
+        self.set_blanks_enabled(show_blanks)
+
+        # Remove trailing whitespaces
+        self.set_remove_trailing_spaces(remove_trailing_spaces)
+
+        # Remove trailing newlines
+        self.set_remove_trailing_newlines(remove_trailing_newlines)
+
+        # Add newline at the end
+        self.set_add_newline(add_newline)
+
+        # Scrolling past the end
+        self.set_scrollpastend_enabled(scroll_past_end)
+
+        # Line number area and indent guides
+        self.toggle_line_numbers(linenumbers, markers)
+
+        # Lexer
+        self.filename = filename
+        self.set_language(language, filename)
+
+        # Underline errors and warnings
+        self.set_underline_errors_enabled(underline_errors)
+
+        # Highlight current cell
+        self.set_highlight_current_cell(highlight_current_cell)
+
+        # Highlight current line
+        self.set_highlight_current_line(highlight_current_line)
+
+        # Occurrence highlighting
+        self.set_occurrence_highlighting(occurrence_highlighting)
+        self.set_occurrence_timeout(occurrence_timeout)
+
+        # Tab always indents (even when cursor is not at the begin of line)
+        self.set_tab_mode(tab_mode)
+
+        # Intelligent backspace
+        self.toggle_intelligent_backspace(intelligent_backspace)
+
+        # Automatic completions
+        self.toggle_automatic_completions(automatic_completions)
+        self.set_automatic_completions_after_chars(
+            automatic_completions_after_chars)
+
+        # Completions hint
+        self.toggle_completions_hint(completions_hint)
+        self.set_completions_hint_after_ms(completions_hint_after_ms)
+
+        # Hover hints
+        self.toggle_hover_hints(hover_hints)
+
+        # Code snippets
+        self.toggle_code_snippets(code_snippets)
+
+        # Autoformat on save
+        self.toggle_format_on_save(format_on_save)
+
+        if cloned_from is not None:
+            self.is_cloned = True
+
+            # This is required for the line number area
+            self.setFont(font)
+
+            # Needed to show indent guides for splited editor panels
+            # See spyder-ide/spyder#10900
+            self.patch = cloned_from.patch
+
+            # Clone text and other properties
+            self.set_as_clone(cloned_from)
+
+            # Refresh panels
+            self.panels.refresh()
+        elif font is not None:
+            self.set_font(font, color_scheme)
+        elif color_scheme is not None:
+            self.set_color_scheme(color_scheme)
+
+        # Set tab spacing after font is set
+        self.set_tab_stop_width_spaces(tab_stop_width_spaces)
+
+        self.toggle_wrap_mode(wrap)
+
+        # Class/Function dropdown will be disabled if we're not in a Python
+        # file.
+        self.classfuncdropdown.setVisible(show_class_func_dropdown
+                                          and self.is_python_like())
+
+        self.set_strip_mode(strip_mode)
+
+    # ---- Debug panel
+    # -------------------------------------------------------------------------
+    # ---- Set different attributes
+    # -------------------------------------------------------------------------
+    def set_folding_panel(self, folding):
+        """Enable/disable folding panel."""
+        folding_panel = self.panels.get(FoldingPanel)
+        folding_panel.setVisible(folding)
+
+    def set_tab_mode(self, enable):
+        """
+        enabled = tab always indent
+        (otherwise tab indents only when cursor is at the beginning of a line)
+        """
+        self.tab_mode = enable
+
+    def set_strip_mode(self, enable):
+        """
+        Strip all trailing spaces if enabled.
+        """
+        self.strip_trailing_spaces_on_modify = enable
+
+    def toggle_intelligent_backspace(self, state):
+        self.intelligent_backspace = state
+
+    def toggle_automatic_completions(self, state):
+        self.automatic_completions = state
+
+    def toggle_hover_hints(self, state):
+        self.hover_hints_enabled = state
+
+    def toggle_code_snippets(self, state):
+        self.code_snippets = state
+
+    def toggle_format_on_save(self, state):
+        self.format_on_save = state
+
+    def toggle_code_folding(self, state):
+        self.code_folding = state
+        self.set_folding_panel(state)
+        if not state and self.indent_guides._enabled:
+            self.code_folding = True
+
+    def toggle_identation_guides(self, state):
+        if state and not self.code_folding:
+            self.code_folding = True
+        self.indent_guides.set_enabled(state)
+
+    def toggle_completions_hint(self, state):
+        """Enable/disable completion hint."""
+        self.completions_hint = state
+
+    def set_automatic_completions_after_chars(self, number):
+        """
+        Set the number of characters after which auto completion is fired.
+        """
+        self.automatic_completions_after_chars = number
+
+    def set_completions_hint_after_ms(self, ms):
+        """
+        Set the amount of time in ms after which the completions hint is shown.
+        """
+        self.completions_hint_after_ms = ms
+
+    def set_close_parentheses_enabled(self, enable):
+        """Enable/disable automatic parentheses insertion feature"""
+        self.close_parentheses_enabled = enable
+        bracket_extension = self.editor_extensions.get(CloseBracketsExtension)
+        if bracket_extension is not None:
+            bracket_extension.enabled = enable
+
+    def set_close_quotes_enabled(self, enable):
+        """Enable/disable automatic quote insertion feature"""
+        self.close_quotes_enabled = enable
+        quote_extension = self.editor_extensions.get(CloseQuotesExtension)
+        if quote_extension is not None:
+            quote_extension.enabled = enable
+
+    def set_add_colons_enabled(self, enable):
+        """Enable/disable automatic colons insertion feature"""
+        self.add_colons_enabled = enable
+
+    def set_auto_unindent_enabled(self, enable):
+        """Enable/disable automatic unindent after else/elif/finally/except"""
+        self.auto_unindent_enabled = enable
+
+    def set_occurrence_highlighting(self, enable):
+        """Enable/disable occurrence highlighting"""
+        self.occurrence_highlighting = enable
+        if not enable:
+            self.clear_occurrences()
+
+    def set_occurrence_timeout(self, timeout):
+        """Set occurrence highlighting timeout (ms)"""
+        self.occurrence_timer.setInterval(timeout)
+
+    def set_underline_errors_enabled(self, state):
+        """Toggle the underlining of errors and warnings."""
+        self.underline_errors_enabled = state
+        if not state:
+            self.clear_extra_selections('code_analysis_underline')
+
+    def set_highlight_current_line(self, enable):
+        """Enable/disable current line highlighting"""
+        self.highlight_current_line_enabled = enable
+        if self.highlight_current_line_enabled:
+            self.highlight_current_line()
+        else:
+            self.unhighlight_current_line()
+
+    def set_highlight_current_cell(self, enable):
+        """Enable/disable current line highlighting"""
+        hl_cell_enable = enable and self.supported_cell_language
+        self.highlight_current_cell_enabled = hl_cell_enable
+        if self.highlight_current_cell_enabled:
+            self.highlight_current_cell()
+        else:
+            self.unhighlight_current_cell()
+
+    def set_language(self, language, filename=None):
+        extra_supported_languages = {'stil': 'STIL'}
+        self.tab_indents = language in self.TAB_ALWAYS_INDENTS
+        self.comment_string = ''
+        self.language = 'Text'
+        self.supported_language = False
+        sh_class = sh.TextSH
+        language = 'None' if language is None else language
+        if language is not None:
+            for (key, value) in ALL_LANGUAGES.items():
+                if language.lower() in value:
+                    self.supported_language = True
+                    sh_class, comment_string = self.LANGUAGES[key]
+                    if key == 'IPython':
+                        self.language = 'Python'
+                    else:
+                        self.language = key
+                    self.comment_string = comment_string
+                    if key in CELL_LANGUAGES:
+                        self.supported_cell_language = True
+                        self.has_cell_separators = True
+                    break
+
+        if filename is not None and not self.supported_language:
+            sh_class = sh.guess_pygments_highlighter(filename)
+            self.support_language = sh_class is not sh.TextSH
+            if self.support_language:
+                # Pygments report S for the lexer name of R files
+                if sh_class._lexer.name == 'S':
+                    self.language = 'R'
+                else:
+                    self.language = sh_class._lexer.name
+            else:
+                _, ext = osp.splitext(filename)
+                ext = ext.lower()
+                if ext in extra_supported_languages:
+                    self.language = extra_supported_languages[ext]
+
+        self._set_highlighter(sh_class)
+        self.completion_widget.set_language(self.language)
+
+    def _set_highlighter(self, sh_class):
+        self.highlighter_class = sh_class
+        if self.highlighter is not None:
+            # Removing old highlighter
+            # TODO: test if leaving parent/document as is eats memory
+            self.highlighter.setParent(None)
+            self.highlighter.setDocument(None)
+        self.highlighter = self.highlighter_class(self.document(),
+                                                  self.font(),
+                                                  self.color_scheme)
+        self._apply_highlighter_color_scheme()
+
+        self.highlighter.editor = self
+        self.highlighter.sig_font_changed.connect(self.sync_font)
+        self._rehighlight_timer.timeout.connect(
+            self.highlighter.rehighlight)
+
+    def sync_font(self):
+        """Highlighter changed font, update."""
+        self.setFont(self.highlighter.font)
+        self.sig_font_changed.emit()
+
+    def get_cell_list(self):
+        """Get all cells."""
+        if self.highlighter is None:
+            return []
+
+        # Filter out old cells
+        def good(oedata):
+            return oedata.is_valid() and oedata.def_type == oedata.CELL
+
+        self.highlighter._cell_list = [
+            oedata for oedata in self.highlighter._cell_list if good(oedata)]
+
+        return sorted(
+            {oedata.block.blockNumber(): oedata
+             for oedata in self.highlighter._cell_list}.items())
+
+    def is_json(self):
+        return (isinstance(self.highlighter, sh.PygmentsSH) and
+                self.highlighter._lexer.name == 'JSON')
+
+    def is_python(self):
+        return self.highlighter_class is sh.PythonSH
+
+    def is_ipython(self):
+        return self.highlighter_class is sh.IPythonSH
+
+    def is_python_or_ipython(self):
+        return self.is_python() or self.is_ipython()
+
+    def is_cython(self):
+        return self.highlighter_class is sh.CythonSH
+
+    def is_enaml(self):
+        return self.highlighter_class is sh.EnamlSH
+
+    def is_python_like(self):
+        return (self.is_python() or self.is_ipython()
+                or self.is_cython() or self.is_enaml())
+
+    def intelligent_tab(self):
+        """Provide intelligent behavior for Tab key press."""
+        leading_text = self.get_text('sol', 'cursor')
+        if not leading_text.strip() or leading_text.endswith('#'):
+            # blank line or start of comment
+            self.indent_or_replace()
+        elif self.in_comment_or_string() and not leading_text.endswith(' '):
+            # in a word in a comment
+            self.do_completion()
+        elif leading_text.endswith('import ') or leading_text[-1] == '.':
+            # blank import or dot completion
+            self.do_completion()
+        elif (leading_text.split()[0] in ['from', 'import'] and
+                ';' not in leading_text):
+            # import line with a single statement
+            #  (prevents lines like: `import pdb; pdb.set_trace()`)
+            self.do_completion()
+        elif leading_text[-1] in '(,' or leading_text.endswith(', '):
+            self.indent_or_replace()
+        elif leading_text.endswith(' '):
+            # if the line ends with a space, indent
+            self.indent_or_replace()
+        elif re.search(r"[^\d\W]\w*\Z", leading_text, re.UNICODE):
+            # if the line ends with a non-whitespace character
+            self.do_completion()
+        else:
+            self.indent_or_replace()
+
+    def intelligent_backtab(self):
+        """Provide intelligent behavior for Shift+Tab key press"""
+        leading_text = self.get_text('sol', 'cursor')
+        if not leading_text.strip():
+            # blank line
+            self.unindent()
+        elif self.in_comment_or_string():
+            self.unindent()
+        elif leading_text[-1] in '(,' or leading_text.endswith(', '):
+            self.show_object_info()
+        else:
+            # if the line ends with any other character but comma
+            self.unindent()
+
+    def rehighlight(self):
+        """Rehighlight the whole document."""
+        if self.highlighter is not None:
+            self.highlighter.rehighlight()
+        if self.highlight_current_cell_enabled:
+            self.highlight_current_cell()
+        else:
+            self.unhighlight_current_cell()
+        if self.highlight_current_line_enabled:
+            self.highlight_current_line()
+        else:
+            self.unhighlight_current_line()
+
+    def trim_trailing_spaces(self):
+        """Remove trailing spaces"""
+        cursor = self.textCursor()
+        cursor.beginEditBlock()
+        cursor.movePosition(QTextCursor.Start)
+        while True:
+            cursor.movePosition(QTextCursor.EndOfBlock)
+            text = to_text_string(cursor.block().text())
+            length = len(text)-len(text.rstrip())
+            if length > 0:
+                cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor,
+                                    length)
+                cursor.removeSelectedText()
+            if cursor.atEnd():
+                break
+            cursor.movePosition(QTextCursor.NextBlock)
+        cursor.endEditBlock()
+
+    def trim_trailing_newlines(self):
+        """Remove extra newlines at the end of the document."""
+        cursor = self.textCursor()
+        cursor.beginEditBlock()
+        cursor.movePosition(QTextCursor.End)
+        line = cursor.blockNumber()
+        this_line = self.get_text_line(line)
+        previous_line = self.get_text_line(line - 1)
+
+        # Don't try to trim new lines for a file with a single line.
+        # Fixes spyder-ide/spyder#16401
+        if self.get_line_count() > 1:
+            while this_line == '':
+                cursor.movePosition(QTextCursor.PreviousBlock,
+                                    QTextCursor.KeepAnchor)
+
+                if self.add_newline:
+                    if this_line == '' and previous_line != '':
+                        cursor.movePosition(QTextCursor.NextBlock,
+                                            QTextCursor.KeepAnchor)
+
+                line -= 1
+                if line == 0:
+                    break
+
+                this_line = self.get_text_line(line)
+                previous_line = self.get_text_line(line - 1)
+
+        if not self.add_newline:
+            cursor.movePosition(QTextCursor.EndOfBlock,
+                                QTextCursor.KeepAnchor)
+
+        cursor.removeSelectedText()
+        cursor.endEditBlock()
+
+    def add_newline_to_file(self):
+        """Add a newline to the end of the file if it does not exist."""
+        cursor = self.textCursor()
+        cursor.movePosition(QTextCursor.End)
+        line = cursor.blockNumber()
+        this_line = self.get_text_line(line)
+        if this_line != '':
+            cursor.beginEditBlock()
+            cursor.movePosition(QTextCursor.EndOfBlock)
+            cursor.insertText(self.get_line_separator())
+            cursor.endEditBlock()
+
+    def fix_indentation(self):
+        """Replace tabs by spaces."""
+        text_before = to_text_string(self.toPlainText())
+        text_after = sourcecode.fix_indentation(text_before, self.indent_chars)
+        if text_before != text_after:
+            # We do the following rather than using self.setPlainText
+            # to benefit from QTextEdit's undo/redo feature.
+            self.selectAll()
+            self.skip_rstrip = True
+            self.insertPlainText(text_after)
+            self.skip_rstrip = False
+
+    def get_current_object(self):
+        """Return current object (string) """
+        source_code = to_text_string(self.toPlainText())
+        offset = self.get_position('cursor')
+        return sourcecode.get_primary_at(source_code, offset)
+
+    def next_cursor_position(self, position=None,
+                             mode=QTextLayout.SkipCharacters):
+        """
+        Get next valid cursor position.
+
+        Adapted from:
+        https://github.com/qt/qtbase/blob/5.15.2/src/gui/text/qtextdocument_p.cpp#L1361
+        """
+        cursor = self.textCursor()
+        if cursor.atEnd():
+            return position
+        if position is None:
+            position = cursor.position()
+        else:
+            cursor.setPosition(position)
+        it = cursor.block()
+        start = it.position()
+        end = start + it.length() - 1
+        if (position == end):
+            return end + 1
+        return it.layout().nextCursorPosition(position - start, mode) + start
+
+    @Slot()
+    def delete(self):
+        """Remove selected text or next character."""
+        if not self.has_selected_text():
+            cursor = self.textCursor()
+            if not cursor.atEnd():
+                cursor.setPosition(
+                    self.next_cursor_position(), QTextCursor.KeepAnchor)
+            self.setTextCursor(cursor)
+        self.remove_selected_text()
+
+    # ---- Scrolling
+    # -------------------------------------------------------------------------
+    def scroll_line_down(self):
+        """Scroll the editor down by one step."""
+        vsb = self.verticalScrollBar()
+        vsb.setValue(vsb.value() + vsb.singleStep())
+
+    def scroll_line_up(self):
+        """Scroll the editor up by one step."""
+        vsb = self.verticalScrollBar()
+        vsb.setValue(vsb.value() - vsb.singleStep())
+
+    # ---- Find occurrences
+    # -------------------------------------------------------------------------
+    def __find_first(self, text):
+        """Find first occurrence: scan whole document"""
+        flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords
+        cursor = self.textCursor()
+        # Scanning whole document
+        cursor.movePosition(QTextCursor.Start)
+        regexp = QRegularExpression(
+            r"\b%s\b" % QRegularExpression.escape(text)
+        )
+        cursor = self.document().find(regexp, cursor, flags)
+        self.__find_first_pos = cursor.position()
+        return cursor
+
+    def __find_next(self, text, cursor):
+        """Find next occurrence"""
+        flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords
+        regexp = QRegularExpression(
+            r"\b%s\b" % QRegularExpression.escape(text)
+        )
+        cursor = self.document().find(regexp, cursor, flags)
+        if cursor.position() != self.__find_first_pos:
+            return cursor
+
+    def __cursor_position_changed(self):
+        """Cursor position has changed"""
+        line, column = self.get_cursor_line_column()
+        self.sig_cursor_position_changed.emit(line, column)
+
+        if self.highlight_current_cell_enabled:
+            self.highlight_current_cell()
+        else:
+            self.unhighlight_current_cell()
+        if self.highlight_current_line_enabled:
+            self.highlight_current_line()
+        else:
+            self.unhighlight_current_line()
+        if self.occurrence_highlighting:
+            self.occurrence_timer.start()
+
+        # Strip if needed
+        self.strip_trailing_spaces()
+
+    def clear_occurrences(self):
+        """Clear occurrence markers"""
+        self.occurrences = []
+        self.clear_extra_selections('occurrences')
+        self.sig_flags_changed.emit()
+
+    def get_selection(self, cursor, foreground_color=None,
+                      background_color=None, underline_color=None,
+                      outline_color=None,
+                      underline_style=QTextCharFormat.SingleUnderline):
+        """Get selection."""
+        if cursor is None:
+            return
+
+        selection = TextDecoration(cursor)
+        if foreground_color is not None:
+            selection.format.setForeground(foreground_color)
+        if background_color is not None:
+            selection.format.setBackground(background_color)
+        if underline_color is not None:
+            selection.format.setProperty(QTextFormat.TextUnderlineStyle,
+                                         to_qvariant(underline_style))
+            selection.format.setProperty(QTextFormat.TextUnderlineColor,
+                                         to_qvariant(underline_color))
+        if outline_color is not None:
+            selection.set_outline(outline_color)
+        return selection
+
+    def highlight_selection(self, key, cursor, foreground_color=None,
+                            background_color=None, underline_color=None,
+                            outline_color=None,
+                            underline_style=QTextCharFormat.SingleUnderline):
+
+        selection = self.get_selection(
+            cursor, foreground_color, background_color, underline_color,
+            outline_color, underline_style)
+        if selection is None:
+            return
+        extra_selections = self.get_extra_selections(key)
+        extra_selections.append(selection)
+        self.set_extra_selections(key, extra_selections)
+
+    def mark_occurrences(self):
+        """Marking occurrences of the currently selected word"""
+        self.clear_occurrences()
+
+        if not self.supported_language:
+            return
+
+        text = self.get_selected_text().strip()
+        if not text:
+            text = self.get_current_word()
+        if text is None:
+            return
+        if (self.has_selected_text() and
+                self.get_selected_text().strip() != text):
+            return
+
+        if (self.is_python_like() and
+                (sourcecode.is_keyword(to_text_string(text)) or
+                 to_text_string(text) == 'self')):
+            return
+
+        # Highlighting all occurrences of word *text*
+        cursor = self.__find_first(text)
+        self.occurrences = []
+        extra_selections = self.get_extra_selections('occurrences')
+        first_occurrence = None
+        while cursor:
+            block = cursor.block()
+            if not block.userData():
+                # Add user data to check block validity
+                block.setUserData(BlockUserData(self))
+            self.occurrences.append(block)
+
+            selection = self.get_selection(cursor)
+            if len(selection.cursor.selectedText()) > 0:
+                extra_selections.append(selection)
+                if len(extra_selections) == 1:
+                    first_occurrence = selection
+                else:
+                    selection.format.setBackground(self.occurrence_color)
+                    first_occurrence.format.setBackground(
+                        self.occurrence_color)
+            cursor = self.__find_next(text, cursor)
+        self.set_extra_selections('occurrences', extra_selections)
+
+        if len(self.occurrences) > 1 and self.occurrences[-1] == 0:
+            # XXX: this is never happening with PySide but it's necessary
+            # for PyQt4... this must be related to a different behavior for
+            # the QTextDocument.find function between those two libraries
+            self.occurrences.pop(-1)
+        self.sig_flags_changed.emit()
+
+    # ---- Highlight found results
+    # -------------------------------------------------------------------------
+    def highlight_found_results(self, pattern, word=False, regexp=False,
+                                case=False):
+        """Highlight all found patterns"""
+        self.__find_args = {
+            'pattern': pattern,
+            'word': word,
+            'regexp': regexp,
+            'case': case,
+        }
+
+        pattern = to_text_string(pattern)
+        if not pattern:
+            return
+        if not regexp:
+            pattern = re.escape(to_text_string(pattern))
+        pattern = r"\b%s\b" % pattern if word else pattern
+        text = to_text_string(self.toPlainText())
+        re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE
+        try:
+            regobj = re.compile(pattern, flags=re_flags)
+        except sre_constants.error:
+            return
+        extra_selections = []
+        self.found_results = []
+        has_unicode = len(text) != qstring_length(text)
+        for match in regobj.finditer(text):
+            if has_unicode:
+                pos1, pos2 = sh.get_span(match)
+            else:
+                pos1, pos2 = match.span()
+            selection = TextDecoration(self.textCursor())
+            selection.format.setBackground(self.found_results_color)
+            selection.cursor.setPosition(pos1)
+
+            block = selection.cursor.block()
+            if not block.userData():
+                # Add user data to check block validity
+                block.setUserData(BlockUserData(self))
+            self.found_results.append(block)
+
+            selection.cursor.setPosition(pos2, QTextCursor.KeepAnchor)
+            extra_selections.append(selection)
+        self.set_extra_selections('find', extra_selections)
+
+    def clear_found_results(self):
+        """Clear found results highlighting"""
+        self.found_results = []
+        self.clear_extra_selections('find')
+        self.sig_flags_changed.emit()
+
+    def __text_has_changed(self):
+        """Text has changed, eventually clear found results highlighting"""
+        self.last_change_position = self.textCursor().position()
+
+        # If the change was on any of the lines were results were found,
+        # rehighlight them.
+        for result in self.found_results:
+            self.highlight_found_results(**self.__find_args)
+            break
+
+    def get_linenumberarea_width(self):
+        """
+        Return current line number area width.
+
+        This method is left for backward compatibility (BaseEditMixin
+        define it), any changes should be in LineNumberArea class.
+        """
+        return self.linenumberarea.get_width()
+
+    def calculate_real_position(self, point):
+        """Add offset to a point, to take into account the panels."""
+        point.setX(point.x() + self.panels.margin_size(Panel.Position.LEFT))
+        point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP))
+        return point
+
+    def calculate_real_position_from_global(self, point):
+        """Add offset to a point, to take into account the panels."""
+        point.setX(point.x() - self.panels.margin_size(Panel.Position.LEFT))
+        point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP))
+        return point
+
+    def get_linenumber_from_mouse_event(self, event):
+        """Return line number from mouse event"""
+        block = self.firstVisibleBlock()
+        line_number = block.blockNumber()
+        top = self.blockBoundingGeometry(block).translated(
+                                                    self.contentOffset()).top()
+        bottom = top + self.blockBoundingRect(block).height()
+        while block.isValid() and top < event.pos().y():
+            block = block.next()
+            if block.isVisible():  # skip collapsed blocks
+                top = bottom
+                bottom = top + self.blockBoundingRect(block).height()
+            line_number += 1
+        return line_number
+
+    def select_lines(self, linenumber_pressed, linenumber_released):
+        """Select line(s) after a mouse press/mouse press drag event"""
+        find_block_by_number = self.document().findBlockByNumber
+        move_n_blocks = (linenumber_released - linenumber_pressed)
+        start_line = linenumber_pressed
+        start_block = find_block_by_number(start_line - 1)
+
+        cursor = self.textCursor()
+        cursor.setPosition(start_block.position())
+
+        # Select/drag downwards
+        if move_n_blocks > 0:
+            for n in range(abs(move_n_blocks) + 1):
+                cursor.movePosition(cursor.NextBlock, cursor.KeepAnchor)
+        # Select/drag upwards or select single line
+        else:
+            cursor.movePosition(cursor.NextBlock)
+            for n in range(abs(move_n_blocks) + 1):
+                cursor.movePosition(cursor.PreviousBlock, cursor.KeepAnchor)
+
+        # Account for last line case
+        if linenumber_released == self.blockCount():
+            cursor.movePosition(cursor.EndOfBlock, cursor.KeepAnchor)
+        else:
+            cursor.movePosition(cursor.StartOfBlock, cursor.KeepAnchor)
+
+        self.setTextCursor(cursor)
+
+    # ---- Code bookmarks
+    # -------------------------------------------------------------------------
+    def add_bookmark(self, slot_num, line=None, column=None):
+        """Add bookmark to current block's userData."""
+        if line is None:
+            # Triggered by shortcut, else by spyder start
+            line, column = self.get_cursor_line_column()
+        block = self.document().findBlockByNumber(line)
+        data = block.userData()
+        if not data:
+            data = BlockUserData(self)
+        if slot_num not in data.bookmarks:
+            data.bookmarks.append((slot_num, column))
+        block.setUserData(data)
+        self._bookmarks_blocks[id(block)] = block
+        self.sig_bookmarks_changed.emit()
+
+    def get_bookmarks(self):
+        """Get bookmarks by going over all blocks."""
+        bookmarks = {}
+        pruned_bookmarks_blocks = {}
+        for block_id in self._bookmarks_blocks:
+            block = self._bookmarks_blocks[block_id]
+            if block.isValid():
+                data = block.userData()
+                if data and data.bookmarks:
+                    pruned_bookmarks_blocks[block_id] = block
+                    line_number = block.blockNumber()
+                    for slot_num, column in data.bookmarks:
+                        bookmarks[slot_num] = [line_number, column]
+        self._bookmarks_blocks = pruned_bookmarks_blocks
+        return bookmarks
+
+    def clear_bookmarks(self):
+        """Clear bookmarks for all blocks."""
+        self.bookmarks = {}
+        for data in self.blockuserdata_list():
+            data.bookmarks = []
+        self._bookmarks_blocks = {}
+
+    def set_bookmarks(self, bookmarks):
+        """Set bookmarks when opening file."""
+        self.clear_bookmarks()
+        for slot_num, bookmark in bookmarks.items():
+            self.add_bookmark(slot_num, bookmark[1], bookmark[2])
+
+    def update_bookmarks(self):
+        """Emit signal to update bookmarks."""
+        self.sig_bookmarks_changed.emit()
+
+    # ---- Code introspection
+    # -------------------------------------------------------------------------
+    def show_completion_object_info(self, name, signature):
+        """Trigger show completion info in Help Pane."""
+        force = True
+        self.sig_show_completion_object_info.emit(name, signature, force)
+
+    @Slot()
+    def show_object_info(self):
+        """Trigger a calltip"""
+        self.sig_show_object_info.emit(True)
+
+    # ---- Blank spaces
+    # -------------------------------------------------------------------------
+    def set_blanks_enabled(self, state):
+        """Toggle blanks visibility"""
+        self.blanks_enabled = state
+        option = self.document().defaultTextOption()
+        option.setFlags(option.flags() | \
+                        QTextOption.AddSpaceForLineAndParagraphSeparators)
+        if self.blanks_enabled:
+            option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces)
+        else:
+            option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces)
+        self.document().setDefaultTextOption(option)
+        # Rehighlight to make the spaces less apparent.
+        self.rehighlight()
+
+    def set_scrollpastend_enabled(self, state):
+        """
+        Allow user to scroll past the end of the document to have the last
+        line on top of the screen
+        """
+        self.scrollpastend_enabled = state
+        self.setCenterOnScroll(state)
+        self.setDocument(self.document())
+
+    def resizeEvent(self, event):
+        """Reimplemented Qt method to handle p resizing"""
+        TextEditBaseWidget.resizeEvent(self, event)
+        self.panels.resize()
+
+    def showEvent(self, event):
+        """Overrides showEvent to update the viewport margins."""
+        super(CodeEditor, self).showEvent(event)
+        self.panels.refresh()
+
+    # ---- Misc.
+    # -------------------------------------------------------------------------
+    def _apply_highlighter_color_scheme(self):
+        """Apply color scheme from syntax highlighter to the editor"""
+        hl = self.highlighter
+        if hl is not None:
+            self.set_palette(background=hl.get_background_color(),
+                             foreground=hl.get_foreground_color())
+            self.currentline_color = hl.get_currentline_color()
+            self.currentcell_color = hl.get_currentcell_color()
+            self.occurrence_color = hl.get_occurrence_color()
+            self.ctrl_click_color = hl.get_ctrlclick_color()
+            self.sideareas_color = hl.get_sideareas_color()
+            self.comment_color = hl.get_comment_color()
+            self.normal_color = hl.get_foreground_color()
+            self.matched_p_color = hl.get_matched_p_color()
+            self.unmatched_p_color = hl.get_unmatched_p_color()
+
+            self.edge_line.update_color()
+            self.indent_guides.update_color()
+
+            self.sig_theme_colors_changed.emit(
+                {'occurrence': self.occurrence_color})
+
+    def apply_highlighter_settings(self, color_scheme=None):
+        """Apply syntax highlighter settings"""
+        if self.highlighter is not None:
+            # Updating highlighter settings (font and color scheme)
+            self.highlighter.setup_formats(self.font())
+            if color_scheme is not None:
+                self.set_color_scheme(color_scheme)
+            else:
+                self._rehighlight_timer.start()
+
+    def set_font(self, font, color_scheme=None):
+        """Set font"""
+        # Note: why using this method to set color scheme instead of
+        #       'set_color_scheme'? To avoid rehighlighting the document twice
+        #       at startup.
+        if color_scheme is not None:
+            self.color_scheme = color_scheme
+        self.setFont(font)
+        self.panels.refresh()
+        self.apply_highlighter_settings(color_scheme)
+
+    def set_color_scheme(self, color_scheme):
+        """Set color scheme for syntax highlighting"""
+        self.color_scheme = color_scheme
+        if self.highlighter is not None:
+            # this calls self.highlighter.rehighlight()
+            self.highlighter.set_color_scheme(color_scheme)
+            self._apply_highlighter_color_scheme()
+        if self.highlight_current_cell_enabled:
+            self.highlight_current_cell()
+        else:
+            self.unhighlight_current_cell()
+        if self.highlight_current_line_enabled:
+            self.highlight_current_line()
+        else:
+            self.unhighlight_current_line()
+
+    def set_text(self, text):
+        """Set the text of the editor"""
+        self.setPlainText(text)
+        self.set_eol_chars(text=text)
+
+        if (isinstance(self.highlighter, sh.PygmentsSH)
+                and not running_under_pytest()):
+            self.highlighter.make_charlist()
+
+    def set_text_from_file(self, filename, language=None):
+        """Set the text of the editor from file *fname*"""
+        self.filename = filename
+        text, _enc = encoding.read(filename)
+        if language is None:
+            language = get_file_language(filename, text)
+        self.set_language(language, filename)
+        self.set_text(text)
+
+    def append(self, text):
+        """Append text to the end of the text widget"""
+        cursor = self.textCursor()
+        cursor.movePosition(QTextCursor.End)
+        cursor.insertText(text)
+
+    def adjust_indentation(self, line, indent_adjustment):
+        """Adjust indentation."""
+        if indent_adjustment == 0 or line == "":
+            return line
+        using_spaces = self.indent_chars != '\t'
+
+        if indent_adjustment > 0:
+            if using_spaces:
+                return ' ' * indent_adjustment + line
+            else:
+                return (
+                    self.indent_chars
+                    * (indent_adjustment // self.tab_stop_width_spaces)
+                    + line)
+
+        max_indent = self.get_line_indentation(line)
+        indent_adjustment = min(max_indent, -indent_adjustment)
+
+        indent_adjustment = (indent_adjustment if using_spaces else
+                             indent_adjustment // self.tab_stop_width_spaces)
+
+        return line[indent_adjustment:]
+
+    @Slot()
+    def paste(self):
+        """
+        Insert text or file/folder path copied from clipboard.
+
+        Reimplement QPlainTextEdit's method to fix the following issue:
+        on Windows, pasted text has only 'LF' EOL chars even if the original
+        text has 'CRLF' EOL chars.
+        The function also changes the clipboard data if they are copied as
+        files/folders but does not change normal text data except if they are
+        multiple lines. Since we are changing clipboard data we cannot use
+        paste, which directly pastes from clipboard instead we use
+        insertPlainText and pass the formatted/changed text without modifying
+        clipboard content.
+        """
+        clipboard = QApplication.clipboard()
+        text = to_text_string(clipboard.text())
+
+        if clipboard.mimeData().hasUrls():
+            # Have copied file and folder urls pasted as text paths.
+            # See spyder-ide/spyder#8644 for details.
+            urls = clipboard.mimeData().urls()
+            if all([url.isLocalFile() for url in urls]):
+                if len(urls) > 1:
+                    sep_chars = ',' + self.get_line_separator()
+                    text = sep_chars.join('"' + url.toLocalFile().
+                                          replace(osp.os.sep, '/')
+                                          + '"' for url in urls)
+                else:
+                    # The `urls` list can be empty, so we need to check that
+                    # before proceeding.
+                    # Fixes spyder-ide/spyder#17521
+                    if urls:
+                        text = urls[0].toLocalFile().replace(osp.os.sep, '/')
+
+        eol_chars = self.get_line_separator()
+        if len(text.splitlines()) > 1:
+            text = eol_chars.join((text + eol_chars).splitlines())
+
+        # Align multiline text based on first line
+        cursor = self.textCursor()
+        cursor.beginEditBlock()
+        cursor.removeSelectedText()
+        cursor.setPosition(cursor.selectionStart())
+        cursor.setPosition(cursor.block().position(),
+                           QTextCursor.KeepAnchor)
+        preceding_text = cursor.selectedText()
+        first_line_selected, *remaining_lines = (text + eol_chars).splitlines()
+        first_line = preceding_text + first_line_selected
+
+        first_line_adjustment = 0
+
+        # Dedent if automatic indentation makes code invalid
+        # Minimum indentation = max of current and paster indentation
+        if (self.is_python_like() and len(preceding_text.strip()) == 0
+                and len(first_line.strip()) > 0):
+            # Correct indentation
+            desired_indent = self.find_indentation()
+            if desired_indent:
+                # minimum indentation is either the current indentation
+                # or the indentation of the paster text
+                desired_indent = max(
+                    desired_indent,
+                    self.get_line_indentation(first_line_selected),
+                    self.get_line_indentation(preceding_text))
+                first_line_adjustment = (
+                    desired_indent - self.get_line_indentation(first_line))
+                # Only dedent, don't indent
+                first_line_adjustment = min(first_line_adjustment, 0)
+                # Only dedent, don't indent
+                first_line = self.adjust_indentation(
+                    first_line, first_line_adjustment)
+
+        # Fix indentation of multiline text based on first line
+        if len(remaining_lines) > 0 and len(first_line.strip()) > 0:
+            lines_adjustment = first_line_adjustment
+            lines_adjustment += CLIPBOARD_HELPER.remaining_lines_adjustment(
+                preceding_text)
+
+            # Make sure the code is not flattened
+            indentations = [
+                self.get_line_indentation(line)
+                for line in remaining_lines if line.strip() != ""]
+            if indentations:
+                max_dedent = min(indentations)
+                lines_adjustment = max(lines_adjustment, -max_dedent)
+            # Get new text
+            remaining_lines = [
+                self.adjust_indentation(line, lines_adjustment)
+                for line in remaining_lines]
+
+        text = eol_chars.join([first_line, *remaining_lines])
+
+        self.skip_rstrip = True
+        self.sig_will_paste_text.emit(text)
+        cursor.removeSelectedText()
+        cursor.insertText(text)
+        cursor.endEditBlock()
+        self.sig_text_was_inserted.emit()
+
+        self.skip_rstrip = False
+
+    def _save_clipboard_indentation(self):
+        """
+        Save the indentation corresponding to the clipboard data.
+
+        Must be called right after copying.
+        """
+        cursor = self.textCursor()
+        cursor.setPosition(cursor.selectionStart())
+        cursor.setPosition(cursor.block().position(),
+                           QTextCursor.KeepAnchor)
+        preceding_text = cursor.selectedText()
+        CLIPBOARD_HELPER.save_indentation(
+            preceding_text, self.tab_stop_width_spaces)
+
+    @Slot()
+    def cut(self):
+        """Reimplement cut to signal listeners about changes on the text."""
+        has_selected_text = self.has_selected_text()
+        if not has_selected_text:
+            return
+        start, end = self.get_selection_start_end()
+        self.sig_will_remove_selection.emit(start, end)
+        TextEditBaseWidget.cut(self)
+        self._save_clipboard_indentation()
+        self.sig_text_was_inserted.emit()
+
+    @Slot()
+    def copy(self):
+        """Reimplement copy to save indentation."""
+        TextEditBaseWidget.copy(self)
+        self._save_clipboard_indentation()
+
+    @Slot()
+    def undo(self):
+        """Reimplement undo to decrease text version number."""
+        if self.document().isUndoAvailable():
+            self.text_version -= 1
+            self.skip_rstrip = True
+            self.is_undoing = True
+            TextEditBaseWidget.undo(self)
+            self.sig_undo.emit()
+            self.sig_text_was_inserted.emit()
+            self.is_undoing = False
+            self.skip_rstrip = False
+
+    @Slot()
+    def redo(self):
+        """Reimplement redo to increase text version number."""
+        if self.document().isRedoAvailable():
+            self.text_version += 1
+            self.skip_rstrip = True
+            self.is_redoing = True
+            TextEditBaseWidget.redo(self)
+            self.sig_redo.emit()
+            self.sig_text_was_inserted.emit()
+            self.is_redoing = False
+            self.skip_rstrip = False
+
+    # ---- High-level editor features
+    # -------------------------------------------------------------------------
+    @Slot()
+    def center_cursor_on_next_focus(self):
+        """QPlainTextEdit's "centerCursor" requires the widget to be visible"""
+        self.centerCursor()
+        self.focus_in.disconnect(self.center_cursor_on_next_focus)
+
+    def go_to_line(self, line, start_column=0, end_column=0, word=''):
+        """Go to line number *line* and eventually highlight it"""
+        self.text_helper.goto_line(line, column=start_column,
+                                   end_column=end_column, move=True,
+                                   word=word)
+
+    def exec_gotolinedialog(self):
+        """Execute the GoToLineDialog dialog box"""
+        dlg = GoToLineDialog(self)
+        if dlg.exec_():
+            self.go_to_line(dlg.get_line_number())
+
+    def hide_tooltip(self):
+        """
+        Hide the tooltip widget.
+
+        The tooltip widget is a special QLabel that looks like a tooltip,
+        this method is here so it can be hidden as necessary. For example,
+        when the user leaves the Linenumber area when hovering over lint
+        warnings and errors.
+        """
+        self._timer_mouse_moving.stop()
+        self._last_hover_word = None
+        self.clear_extra_selections('code_analysis_highlight')
+        if self.tooltip_widget.isVisible():
+            self.tooltip_widget.hide()
+
+    def _set_completions_hint_idle(self):
+        self._completions_hint_idle = True
+        self.completion_widget.trigger_completion_hint()
+
+    def show_hint_for_completion(self, word, documentation, at_point):
+        """Show hint for completion element."""
+        if self.completions_hint and self._completions_hint_idle:
+            documentation = documentation.replace(u'\xa0', ' ')
+            completion_doc = {'name': word,
+                              'signature': documentation}
+
+            if documentation and len(documentation) > 0:
+                self.show_hint(
+                    documentation,
+                    inspect_word=word,
+                    at_point=at_point,
+                    completion_doc=completion_doc,
+                    max_lines=self._DEFAULT_MAX_LINES,
+                    max_width=self._DEFAULT_COMPLETION_HINT_MAX_WIDTH)
+                self.tooltip_widget.move(at_point)
+                return
+        self.hide_tooltip()
+
+    def update_decorations(self):
+        """Update decorations on the visible portion of the screen."""
+        if self.underline_errors_enabled:
+            self.underline_errors()
+
+        # This is required to update decorations whether there are or not
+        # underline errors in the visible portion of the screen.
+        # See spyder-ide/spyder#14268.
+        self.decorations.update()
+
+    def show_code_analysis_results(self, line_number, block_data):
+        """Show warning/error messages."""
+        # Diagnostic severity
+        icons = {
+            DiagnosticSeverity.ERROR: 'error',
+            DiagnosticSeverity.WARNING: 'warning',
+            DiagnosticSeverity.INFORMATION: 'information',
+            DiagnosticSeverity.HINT: 'hint',
+        }
+
+        code_analysis = block_data.code_analysis
+
+        # Size must be adapted from font
+        fm = self.fontMetrics()
+        size = fm.height()
+        template = (
+            '<img src="data:image/png;base64, {}"'
+            ' height="{size}" width="{size}" />&nbsp;'
+            '{} <i>({} {})</i>'
+        )
+
+        msglist = []
+        max_lines_msglist = 25
+        sorted_code_analysis = sorted(code_analysis, key=lambda i: i[2])
+        for src, code, sev, msg in sorted_code_analysis:
+            if src == 'pylint' and '[' in msg and ']' in msg:
+                # Remove extra redundant info from pylint messages
+                msg = msg.split(']')[-1]
+
+            msg = msg.strip()
+            # Avoid messing TODO, FIXME
+            # Prevent error if msg only has one element
+            if len(msg) > 1:
+                msg = msg[0].upper() + msg[1:]
+
+            # Get individual lines following paragraph format and handle
+            # symbols like '<' and '>' to not mess with br tags
+            msg = msg.replace('<', '&lt;').replace('>', '&gt;')
+            paragraphs = msg.splitlines()
+            new_paragraphs = []
+            long_paragraphs = 0
+            lines_per_message = 6
+            for paragraph in paragraphs:
+                new_paragraph = textwrap.wrap(
+                    paragraph,
+                    width=self._DEFAULT_MAX_HINT_WIDTH)
+                if lines_per_message > 2:
+                    if len(new_paragraph) > 1:
+                        new_paragraph = '<br>'.join(new_paragraph[:2]) + '...'
+                        long_paragraphs += 1
+                        lines_per_message -= 2
+                    else:
+                        new_paragraph = '<br>'.join(new_paragraph)
+                        lines_per_message -= 1
+                    new_paragraphs.append(new_paragraph)
+
+            if len(new_paragraphs) > 1:
+                # Define max lines taking into account that in the same
+                # tooltip you can find multiple warnings and messages
+                # and each one can have multiple lines
+                if long_paragraphs != 0:
+                    max_lines = 3
+                    max_lines_msglist -= max_lines * 2
+                else:
+                    max_lines = 5
+                    max_lines_msglist -= max_lines
+                msg = '<br>'.join(new_paragraphs[:max_lines]) + '<br>'
+            else:
+                msg = '<br>'.join(new_paragraphs)
+
+            base_64 = ima.base64_from_icon(icons[sev], size, size)
+            if max_lines_msglist >= 0:
+                msglist.append(template.format(base_64, msg, src,
+                                               code, size=size))
+
+        if msglist:
+            self.show_tooltip(
+                title=_("Code analysis"),
+                text='\n'.join(msglist),
+                title_color=QStylePalette.COLOR_ACCENT_4,
+                at_line=line_number,
+                with_html_format=True
+            )
+            self.highlight_line_warning(block_data)
+
+    def highlight_line_warning(self, block_data):
+        """Highlight errors and warnings in this editor."""
+        self.clear_extra_selections('code_analysis_highlight')
+        self.highlight_selection('code_analysis_highlight',
+                                 block_data._selection(),
+                                 background_color=block_data.color)
+        self.linenumberarea.update()
+
+    def get_current_warnings(self):
+        """
+        Get all warnings for the current editor and return
+        a list with the message and line number.
+        """
+        block = self.document().firstBlock()
+        line_count = self.document().blockCount()
+        warnings = []
+        while True:
+            data = block.userData()
+            if data and data.code_analysis:
+                for warning in data.code_analysis:
+                    warnings.append([warning[-1], block.blockNumber() + 1])
+            # See spyder-ide/spyder#9924
+            if block.blockNumber() + 1 == line_count:
+                break
+            block = block.next()
+        return warnings
+
+    def go_to_next_warning(self):
+        """
+        Go to next code warning message and return new cursor position.
+        """
+        block = self.textCursor().block()
+        line_count = self.document().blockCount()
+        for __ in range(line_count):
+            line_number = block.blockNumber() + 1
+            if line_number < line_count:
+                block = block.next()
+            else:
+                block = self.document().firstBlock()
+
+            data = block.userData()
+            if data and data.code_analysis:
+                line_number = block.blockNumber() + 1
+                self.go_to_line(line_number)
+                self.show_code_analysis_results(line_number, data)
+                return self.get_position('cursor')
+
+    def go_to_previous_warning(self):
+        """
+        Go to previous code warning message and return new cursor position.
+        """
+        block = self.textCursor().block()
+        line_count = self.document().blockCount()
+        for __ in range(line_count):
+            line_number = block.blockNumber() + 1
+            if line_number > 1:
+                block = block.previous()
+            else:
+                block = self.document().lastBlock()
+
+            data = block.userData()
+            if data and data.code_analysis:
+                line_number = block.blockNumber() + 1
+                self.go_to_line(line_number)
+                self.show_code_analysis_results(line_number, data)
+                return self.get_position('cursor')
+
+    def cell_list(self):
+        """Get the outline explorer data for all cells."""
+        for oedata in self.outlineexplorer_data_list():
+            if oedata.def_type == OED.CELL:
+                yield oedata
+
+    def get_cell_code(self, cell):
+        """
+        Get cell code for a given cell.
+
+        If the cell doesn't exist, raises an exception
+        """
+        selected_block = None
+        if is_string(cell):
+            for oedata in self.cell_list():
+                if oedata.def_name == cell:
+                    selected_block = oedata.block
+                    break
+        else:
+            if cell == 0:
+                selected_block = self.document().firstBlock()
+            else:
+                cell_list = list(self.cell_list())
+                if cell <= len(cell_list):
+                    selected_block = cell_list[cell - 1].block
+
+        if not selected_block:
+            raise RuntimeError("Cell {} not found.".format(repr(cell)))
+
+        cursor = QTextCursor(selected_block)
+        text, _, off_pos, col_pos = self.get_cell_as_executable_code(cursor)
+        return text
+
+    def get_cell_code_and_position(self, cell):
+        """
+        Get code and position for a given cell.
+
+        If the cell doesn't exist, raise an exception.
+        """
+        selected_block = None
+        if is_string(cell):
+            for oedata in self.cell_list():
+                if oedata.def_name == cell:
+                    selected_block = oedata.block
+                    break
+        else:
+            if cell == 0:
+                selected_block = self.document().firstBlock()
+            else:
+                cell_list = list(self.cell_list())
+                if cell <= len(cell_list):
+                    selected_block = cell_list[cell - 1].block
+
+        if not selected_block:
+            raise RuntimeError("Cell {} not found.".format(repr(cell)))
+
+        cursor = QTextCursor(selected_block)
+        text, _, off_pos, col_pos = self.get_cell_as_executable_code(cursor)
+        return text, off_pos, col_pos
+
+    def get_cell_count(self):
+        """Get number of cells in document."""
+        return 1 + len(list(self.cell_list()))
+
+    # ---- Tasks management
+    # -------------------------------------------------------------------------
+    def go_to_next_todo(self):
+        """Go to next todo and return new cursor position"""
+        block = self.textCursor().block()
+        line_count = self.document().blockCount()
+        while True:
+            if block.blockNumber()+1 < line_count:
+                block = block.next()
+            else:
+                block = self.document().firstBlock()
+            data = block.userData()
+            if data and data.todo:
+                break
+        line_number = block.blockNumber()+1
+        self.go_to_line(line_number)
+        self.show_tooltip(
+            title=_("To do"),
+            text=data.todo,
+            title_color=QStylePalette.COLOR_ACCENT_4,
+            at_line=line_number,
+        )
+
+        return self.get_position('cursor')
+
+    def process_todo(self, todo_results):
+        """Process todo finder results"""
+        for data in self.blockuserdata_list():
+            data.todo = ''
+
+        for message, line_number in todo_results:
+            block = self.document().findBlockByNumber(line_number - 1)
+            data = block.userData()
+            if not data:
+                data = BlockUserData(self)
+            data.todo = message
+            block.setUserData(data)
+        self.sig_flags_changed.emit()
+
+    # ---- Comments/Indentation
+    # -------------------------------------------------------------------------
+    def add_prefix(self, prefix):
+        """Add prefix to current line or selected line(s)"""
+        cursor = self.textCursor()
+        if self.has_selected_text():
+            # Add prefix to selected line(s)
+            start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
+
+            # Let's see if selection begins at a block start
+            first_pos = min([start_pos, end_pos])
+            first_cursor = self.textCursor()
+            first_cursor.setPosition(first_pos)
+
+            cursor.beginEditBlock()
+            cursor.setPosition(end_pos)
+            # Check if end_pos is at the start of a block: if so, starting
+            # changes from the previous block
+            if cursor.atBlockStart():
+                cursor.movePosition(QTextCursor.PreviousBlock)
+                if cursor.position() < start_pos:
+                    cursor.setPosition(start_pos)
+            move_number = self.__spaces_for_prefix()
+
+            while cursor.position() >= start_pos:
+                cursor.movePosition(QTextCursor.StartOfBlock)
+                line_text = to_text_string(cursor.block().text())
+                if (self.get_character(cursor.position()) == ' '
+                        and '#' in prefix and not line_text.isspace()
+                        or (not line_text.startswith(' ')
+                            and line_text != '')):
+                    cursor.movePosition(QTextCursor.Right,
+                                        QTextCursor.MoveAnchor,
+                                        move_number)
+                    cursor.insertText(prefix)
+                elif '#' not in prefix:
+                    cursor.insertText(prefix)
+                if cursor.blockNumber() == 0:
+                    # Avoid infinite loop when indenting the very first line
+                    break
+                cursor.movePosition(QTextCursor.PreviousBlock)
+                cursor.movePosition(QTextCursor.EndOfBlock)
+            cursor.endEditBlock()
+        else:
+            # Add prefix to current line
+            cursor.beginEditBlock()
+            cursor.movePosition(QTextCursor.StartOfBlock)
+            if self.get_character(cursor.position()) == ' ' and '#' in prefix:
+                cursor.movePosition(QTextCursor.NextWord)
+            cursor.insertText(prefix)
+            cursor.endEditBlock()
+
+    def __spaces_for_prefix(self):
+        """Find the less indented level of text."""
+        cursor = self.textCursor()
+        if self.has_selected_text():
+            # Add prefix to selected line(s)
+            start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
+
+            # Let's see if selection begins at a block start
+            first_pos = min([start_pos, end_pos])
+            first_cursor = self.textCursor()
+            first_cursor.setPosition(first_pos)
+
+            cursor.beginEditBlock()
+            cursor.setPosition(end_pos)
+            # Check if end_pos is at the start of a block: if so, starting
+            # changes from the previous block
+            if cursor.atBlockStart():
+                cursor.movePosition(QTextCursor.PreviousBlock)
+                if cursor.position() < start_pos:
+                    cursor.setPosition(start_pos)
+
+            number_spaces = -1
+            while cursor.position() >= start_pos:
+                cursor.movePosition(QTextCursor.StartOfBlock)
+                line_text = to_text_string(cursor.block().text())
+                start_with_space = line_text.startswith(' ')
+                left_number_spaces = self.__number_of_spaces(line_text)
+                if not start_with_space:
+                    left_number_spaces = 0
+                if ((number_spaces == -1
+                        or number_spaces > left_number_spaces)
+                        and not line_text.isspace() and line_text != ''):
+                    number_spaces = left_number_spaces
+                if cursor.blockNumber() == 0:
+                    # Avoid infinite loop when indenting the very first line
+                    break
+                cursor.movePosition(QTextCursor.PreviousBlock)
+                cursor.movePosition(QTextCursor.EndOfBlock)
+            cursor.endEditBlock()
+        return number_spaces
+
+    def remove_suffix(self, suffix):
+        """
+        Remove suffix from current line (there should not be any selection)
+        """
+        cursor = self.textCursor()
+        cursor.setPosition(cursor.position() - qstring_length(suffix),
+                           QTextCursor.KeepAnchor)
+        if to_text_string(cursor.selectedText()) == suffix:
+            cursor.removeSelectedText()
+
+    def remove_prefix(self, prefix):
+        """Remove prefix from current line or selected line(s)"""
+        cursor = self.textCursor()
+        if self.has_selected_text():
+            # Remove prefix from selected line(s)
+            start_pos, end_pos = sorted([cursor.selectionStart(),
+                                         cursor.selectionEnd()])
+            cursor.setPosition(start_pos)
+            if not cursor.atBlockStart():
+                cursor.movePosition(QTextCursor.StartOfBlock)
+                start_pos = cursor.position()
+            cursor.beginEditBlock()
+            cursor.setPosition(end_pos)
+            # Check if end_pos is at the start of a block: if so, starting
+            # changes from the previous block
+            if cursor.atBlockStart():
+                cursor.movePosition(QTextCursor.PreviousBlock)
+                if cursor.position() < start_pos:
+                    cursor.setPosition(start_pos)
+
+            cursor.movePosition(QTextCursor.StartOfBlock)
+            old_pos = None
+            while cursor.position() >= start_pos:
+                new_pos = cursor.position()
+                if old_pos == new_pos:
+                    break
+                else:
+                    old_pos = new_pos
+                line_text = to_text_string(cursor.block().text())
+                self.__remove_prefix(prefix, cursor, line_text)
+                cursor.movePosition(QTextCursor.PreviousBlock)
+            cursor.endEditBlock()
+        else:
+            # Remove prefix from current line
+            cursor.movePosition(QTextCursor.StartOfBlock)
+            line_text = to_text_string(cursor.block().text())
+            self.__remove_prefix(prefix, cursor, line_text)
+
+    def __remove_prefix(self, prefix, cursor, line_text):
+        """Handle the removal of the prefix for a single line."""
+        cursor.movePosition(QTextCursor.Right,
+                            QTextCursor.MoveAnchor,
+                            line_text.find(prefix))
+        # Handle prefix remove for comments with spaces
+        if (prefix.strip() and line_text.lstrip().startswith(prefix + ' ')
+                or line_text.startswith(prefix + ' ') and '#' in prefix):
+            cursor.movePosition(QTextCursor.Right,
+                                QTextCursor.KeepAnchor, len(prefix + ' '))
+        # Check for prefix without space
+        elif (prefix.strip() and line_text.lstrip().startswith(prefix)
+                or line_text.startswith(prefix)):
+            cursor.movePosition(QTextCursor.Right,
+                                QTextCursor.KeepAnchor, len(prefix))
+        cursor.removeSelectedText()
+
+    def __even_number_of_spaces(self, line_text, group=0):
+        """
+        Get if there is a correct indentation from a group of spaces of a line.
+        """
+        spaces = re.findall(r'\s+', line_text)
+        if len(spaces) - 1 >= group:
+            return len(spaces[group]) % len(self.indent_chars) == 0
+
+    def __number_of_spaces(self, line_text, group=0):
+        """Get the number of spaces from a group of spaces in a line."""
+        spaces = re.findall(r'\s+', line_text)
+        if len(spaces) - 1 >= group:
+            return len(spaces[group])
+
+    def __get_brackets(self, line_text, closing_brackets=[]):
+        """
+        Return unmatched opening brackets and left-over closing brackets.
+
+        (str, []) -> ([(pos, bracket)], [bracket], comment_pos)
+
+        Iterate through line_text to find unmatched brackets.
+
+        Returns three objects as a tuple:
+        1) bracket_stack:
+            a list of tuples of pos and char of each unmatched opening bracket
+        2) closing brackets:
+            this line's unmatched closing brackets + arg closing_brackets.
+            If this line ad no closing brackets, arg closing_brackets might
+            be matched with previously unmatched opening brackets in this line.
+        3) Pos at which a # comment begins. -1 if it doesn't.'
+        """
+        # Remove inline comment and check brackets
+        bracket_stack = []  # list containing this lines unmatched opening
+        # same deal, for closing though. Ignore if bracket stack not empty,
+        # since they are mismatched in that case.
+        bracket_unmatched_closing = []
+        comment_pos = -1
+        deactivate = None
+        escaped = False
+        pos, c = None, None
+        for pos, c in enumerate(line_text):
+            # Handle '\' inside strings
+            if escaped:
+                escaped = False
+            # Handle strings
+            elif deactivate:
+                if c == deactivate:
+                    deactivate = None
+                elif c == "\\":
+                    escaped = True
+            elif c in ["'", '"']:
+                deactivate = c
+            # Handle comments
+            elif c == "#":
+                comment_pos = pos
+                break
+            # Handle brackets
+            elif c in ('(', '[', '{'):
+                bracket_stack.append((pos, c))
+            elif c in (')', ']', '}'):
+                if bracket_stack and bracket_stack[-1][1] == \
+                        {')': '(', ']': '[', '}': '{'}[c]:
+                    bracket_stack.pop()
+                else:
+                    bracket_unmatched_closing.append(c)
+        del pos, deactivate, escaped
+        # If no closing brackets are left over from this line,
+        # check the ones from previous iterations' prevlines
+        if not bracket_unmatched_closing:
+            for c in list(closing_brackets):
+                if bracket_stack and bracket_stack[-1][1] == \
+                        {')': '(', ']': '[', '}': '{'}[c]:
+                    bracket_stack.pop()
+                    closing_brackets.remove(c)
+                else:
+                    break
+        del c
+        closing_brackets = bracket_unmatched_closing + closing_brackets
+        return (bracket_stack, closing_brackets, comment_pos)
+
+    def fix_indent(self, *args, **kwargs):
+        """Indent line according to the preferences"""
+        if self.is_python_like():
+            ret = self.fix_indent_smart(*args, **kwargs)
+        else:
+            ret = self.simple_indentation(*args, **kwargs)
+        return ret
+
+    def simple_indentation(self, forward=True, **kwargs):
+        """
+        Simply preserve the indentation-level of the previous line.
+        """
+        cursor = self.textCursor()
+        block_nb = cursor.blockNumber()
+        prev_block = self.document().findBlockByNumber(block_nb - 1)
+        prevline = to_text_string(prev_block.text())
+
+        indentation = re.match(r"\s*", prevline).group()
+        # Unident
+        if not forward:
+            indentation = indentation[len(self.indent_chars):]
+
+        cursor.insertText(indentation)
+        return False  # simple indentation don't fix indentation
+
+    def find_indentation(self, forward=True, comment_or_string=False,
+                         cur_indent=None):
+        """
+        Find indentation (Python only, no text selection)
+
+        forward=True: fix indent only if text is not enough indented
+                      (otherwise force indent)
+        forward=False: fix indent only if text is too much indented
+                       (otherwise force unindent)
+
+        comment_or_string: Do not adjust indent level for
+            unmatched opening brackets and keywords
+
+        max_blank_lines: maximum number of blank lines to search before giving
+            up
+
+        cur_indent: current indent. This is the indent before we started
+            processing. E.g. when returning, indent before rstrip.
+
+        Returns the indentation for the current line
+
+        Assumes self.is_python_like() to return True
+        """
+        cursor = self.textCursor()
+        block_nb = cursor.blockNumber()
+        # find the line that contains our scope
+        line_in_block = False
+        visual_indent = False
+        add_indent = 0  # How many levels of indent to add
+        prevline = None
+        prevtext = ""
+        empty_lines = True
+
+        closing_brackets = []
+        for prevline in range(block_nb - 1, -1, -1):
+            cursor.movePosition(QTextCursor.PreviousBlock)
+            prevtext = to_text_string(cursor.block().text()).rstrip()
+
+            bracket_stack, closing_brackets, comment_pos = self.__get_brackets(
+                prevtext, closing_brackets)
+
+            if not prevtext:
+                continue
+
+            if prevtext.endswith((':', '\\')):
+                # Presume a block was started
+                line_in_block = True  # add one level of indent to correct_indent
+                # Does this variable actually do *anything* of relevance?
+                # comment_or_string = True
+
+            if bracket_stack or not closing_brackets:
+                break
+
+            if prevtext.strip() != '':
+                empty_lines = False
+
+        if empty_lines and prevline is not None and prevline < block_nb - 2:
+            # The previous line is too far, ignore
+            prevtext = ''
+            prevline = block_nb - 2
+            line_in_block = False
+
+        # splits of prevtext happen a few times. Let's just do it once
+        words = re.split(r'[\s\(\[\{\}\]\)]', prevtext.lstrip())
+
+        if line_in_block:
+            add_indent += 1
+
+        if prevtext and not comment_or_string:
+            if bracket_stack:
+                # Hanging indent
+                if prevtext.endswith(('(', '[', '{')):
+                    add_indent += 1
+                    if words[0] in ('class', 'def', 'elif', 'except', 'for',
+                                    'if', 'while', 'with'):
+                        add_indent += 1
+                    elif not (  # I'm not sure this block should exist here
+                            (
+                                self.tab_stop_width_spaces
+                                if self.indent_chars == '\t' else
+                                len(self.indent_chars)
+                            ) * 2 < len(prevtext)):
+                        visual_indent = True
+                else:
+                    # There's stuff after unmatched opening brackets
+                    visual_indent = True
+            elif (words[-1] in ('continue', 'break', 'pass',)
+                  or words[0] == "return" and not line_in_block
+                  ):
+                add_indent -= 1
+
+        if prevline:
+            prevline_indent = self.get_block_indentation(prevline)
+        else:
+            prevline_indent = 0
+
+        if visual_indent:  # can only be true if bracket_stack
+            correct_indent = bracket_stack[-1][0] + 1
+        elif add_indent:
+            # Indent
+            if self.indent_chars == '\t':
+                correct_indent = prevline_indent + self.tab_stop_width_spaces * add_indent
+            else:
+                correct_indent = prevline_indent + len(self.indent_chars) * add_indent
+        else:
+            correct_indent = prevline_indent
+
+        # TODO untangle this block
+        if prevline and not bracket_stack and not prevtext.endswith(':'):
+            if forward:
+                # Keep indentation of previous line
+                ref_line = block_nb - 1
+            else:
+                # Find indentation context
+                ref_line = prevline
+            if cur_indent is None:
+                cur_indent = self.get_block_indentation(ref_line)
+            is_blank = not self.get_text_line(ref_line).strip()
+            trailing_text = self.get_text_line(block_nb).strip()
+            # If brackets are matched and no block gets opened
+            # Match the above line's indent and nudge to the next multiple of 4
+
+            if cur_indent < prevline_indent and (trailing_text or is_blank):
+                # if line directly above is blank or there is text after cursor
+                # Ceiling division
+                correct_indent = -(-cur_indent // len(self.indent_chars)) * \
+                    len(self.indent_chars)
+        return correct_indent
+
+    def fix_indent_smart(self, forward=True, comment_or_string=False,
+                         cur_indent=None):
+        """
+        Fix indentation (Python only, no text selection)
+
+        forward=True: fix indent only if text is not enough indented
+                      (otherwise force indent)
+        forward=False: fix indent only if text is too much indented
+                       (otherwise force unindent)
+
+        comment_or_string: Do not adjust indent level for
+            unmatched opening brackets and keywords
+
+        max_blank_lines: maximum number of blank lines to search before giving
+            up
+
+        cur_indent: current indent. This is the indent before we started
+            processing. E.g. when returning, indent before rstrip.
+
+        Returns True if indent needed to be fixed
+
+        Assumes self.is_python_like() to return True
+        """
+        cursor = self.textCursor()
+        block_nb = cursor.blockNumber()
+        indent = self.get_block_indentation(block_nb)
+
+        correct_indent = self.find_indentation(
+            forward, comment_or_string, cur_indent)
+
+        if correct_indent >= 0 and not (
+                indent == correct_indent or
+                forward and indent > correct_indent or
+                not forward and indent < correct_indent
+                ):
+            # Insert the determined indent
+            cursor = self.textCursor()
+            cursor.movePosition(QTextCursor.StartOfBlock)
+            if self.indent_chars == '\t':
+                indent = indent // self.tab_stop_width_spaces
+            cursor.setPosition(cursor.position()+indent, QTextCursor.KeepAnchor)
+            cursor.removeSelectedText()
+            if self.indent_chars == '\t':
+                indent_text = (
+                    '\t' * (correct_indent // self.tab_stop_width_spaces) +
+                    ' ' * (correct_indent % self.tab_stop_width_spaces)
+                )
+            else:
+                indent_text = ' '*correct_indent
+            cursor.insertText(indent_text)
+            return True
+        return False
+
+    @Slot()
+    def clear_all_output(self):
+        """Removes all output in the ipynb format (Json only)"""
+        try:
+            nb = nbformat.reads(self.toPlainText(), as_version=4)
+            if nb.cells:
+                for cell in nb.cells:
+                    if 'outputs' in cell:
+                        cell['outputs'] = []
+                    if 'prompt_number' in cell:
+                        cell['prompt_number'] = None
+            # We do the following rather than using self.setPlainText
+            # to benefit from QTextEdit's undo/redo feature.
+            self.selectAll()
+            self.skip_rstrip = True
+            self.insertPlainText(nbformat.writes(nb))
+            self.skip_rstrip = False
+        except Exception as e:
+            QMessageBox.critical(self, _('Removal error'),
+                           _("It was not possible to remove outputs from "
+                             "this notebook. The error is:\n\n") + \
+                             to_text_string(e))
+            return
+
+    @Slot()
+    def convert_notebook(self):
+        """Convert an IPython notebook to a Python script in editor"""
+        try:
+            nb = nbformat.reads(self.toPlainText(), as_version=4)
+            script = nbexporter().from_notebook_node(nb)[0]
+        except Exception as e:
+            QMessageBox.critical(self, _('Conversion error'),
+                                 _("It was not possible to convert this "
+                                 "notebook. The error is:\n\n") + \
+                                 to_text_string(e))
+            return
+        self.sig_new_file.emit(script)
+
+    def indent(self, force=False):
+        """
+        Indent current line or selection
+
+        force=True: indent even if cursor is not a the beginning of the line
+        """
+        leading_text = self.get_text('sol', 'cursor')
+        if self.has_selected_text():
+            self.add_prefix(self.indent_chars)
+        elif (force or not leading_text.strip() or
+                (self.tab_indents and self.tab_mode)):
+            if self.is_python_like():
+                if not self.fix_indent(forward=True):
+                    self.add_prefix(self.indent_chars)
+            else:
+                self.add_prefix(self.indent_chars)
+        else:
+            if len(self.indent_chars) > 1:
+                length = len(self.indent_chars)
+                self.insert_text(" "*(length-(len(leading_text) % length)))
+            else:
+                self.insert_text(self.indent_chars)
+
+    def indent_or_replace(self):
+        """Indent or replace by 4 spaces depending on selection and tab mode"""
+        if (self.tab_indents and self.tab_mode) or not self.has_selected_text():
+            self.indent()
+        else:
+            cursor = self.textCursor()
+            if (self.get_selected_text() ==
+                    to_text_string(cursor.block().text())):
+                self.indent()
+            else:
+                cursor1 = self.textCursor()
+                cursor1.setPosition(cursor.selectionStart())
+                cursor2 = self.textCursor()
+                cursor2.setPosition(cursor.selectionEnd())
+                if cursor1.blockNumber() != cursor2.blockNumber():
+                    self.indent()
+                else:
+                    self.replace(self.indent_chars)
+
+    def unindent(self, force=False):
+        """
+        Unindent current line or selection
+
+        force=True: unindent even if cursor is not a the beginning of the line
+        """
+        if self.has_selected_text():
+            if self.indent_chars == "\t":
+                # Tabs, remove one tab
+                self.remove_prefix(self.indent_chars)
+            else:
+                # Spaces
+                space_count = len(self.indent_chars)
+                leading_spaces = self.__spaces_for_prefix()
+                remainder = leading_spaces % space_count
+                if remainder:
+                    # Get block on "space multiple grid".
+                    # See spyder-ide/spyder#5734.
+                    self.remove_prefix(" "*remainder)
+                else:
+                    # Unindent one space multiple
+                    self.remove_prefix(self.indent_chars)
+        else:
+            leading_text = self.get_text('sol', 'cursor')
+            if (force or not leading_text.strip() or
+                    (self.tab_indents and self.tab_mode)):
+                if self.is_python_like():
+                    if not self.fix_indent(forward=False):
+                        self.remove_prefix(self.indent_chars)
+                elif leading_text.endswith('\t'):
+                    self.remove_prefix('\t')
+                else:
+                    self.remove_prefix(self.indent_chars)
+
+    @Slot()
+    def toggle_comment(self):
+        """Toggle comment on current line or selection"""
+        cursor = self.textCursor()
+        start_pos, end_pos = sorted([cursor.selectionStart(),
+                                     cursor.selectionEnd()])
+        cursor.setPosition(end_pos)
+        last_line = cursor.block().blockNumber()
+        if cursor.atBlockStart() and start_pos != end_pos:
+            last_line -= 1
+        cursor.setPosition(start_pos)
+        first_line = cursor.block().blockNumber()
+        # If the selection contains only commented lines and surrounding
+        # whitespace, uncomment. Otherwise, comment.
+        is_comment_or_whitespace = True
+        at_least_one_comment = False
+        for _line_nb in range(first_line, last_line+1):
+            text = to_text_string(cursor.block().text()).lstrip()
+            is_comment = text.startswith(self.comment_string)
+            is_whitespace = (text == '')
+            is_comment_or_whitespace *= (is_comment or is_whitespace)
+            if is_comment:
+                at_least_one_comment = True
+            cursor.movePosition(QTextCursor.NextBlock)
+        if is_comment_or_whitespace and at_least_one_comment:
+            self.uncomment()
+        else:
+            self.comment()
+
+    def is_comment(self, block):
+        """Detect inline comments.
+
+        Return True if the block is an inline comment.
+        """
+        if block is None:
+            return False
+        text = to_text_string(block.text()).lstrip()
+        return text.startswith(self.comment_string)
+
+    def comment(self):
+        """Comment current line or selection."""
+        self.add_prefix(self.comment_string + ' ')
+
+    def uncomment(self):
+        """Uncomment current line or selection."""
+        blockcomment = self.unblockcomment()
+        if not blockcomment:
+            self.remove_prefix(self.comment_string)
+
+    def __blockcomment_bar(self, compatibility=False):
+        """Handle versions of blockcomment bar for backwards compatibility."""
+        # Blockcomment bar in Spyder version >= 4
+        blockcomment_bar = self.comment_string + ' ' + '=' * (
+                                    79 - len(self.comment_string + ' '))
+        if compatibility:
+            # Blockcomment bar in Spyder version < 4
+            blockcomment_bar = self.comment_string + '=' * (
+                                    79 - len(self.comment_string))
+        return blockcomment_bar
+
+    def transform_to_uppercase(self):
+        """Change to uppercase current line or selection."""
+        cursor = self.textCursor()
+        prev_pos = cursor.position()
+        selected_text = to_text_string(cursor.selectedText())
+
+        if len(selected_text) == 0:
+            prev_pos = cursor.position()
+            cursor.select(QTextCursor.WordUnderCursor)
+            selected_text = to_text_string(cursor.selectedText())
+
+        s = selected_text.upper()
+        cursor.insertText(s)
+        self.set_cursor_position(prev_pos)
+
+    def transform_to_lowercase(self):
+        """Change to lowercase current line or selection."""
+        cursor = self.textCursor()
+        prev_pos = cursor.position()
+        selected_text = to_text_string(cursor.selectedText())
+
+        if len(selected_text) == 0:
+            prev_pos = cursor.position()
+            cursor.select(QTextCursor.WordUnderCursor)
+            selected_text = to_text_string(cursor.selectedText())
+
+        s = selected_text.lower()
+        cursor.insertText(s)
+        self.set_cursor_position(prev_pos)
+
+    def blockcomment(self):
+        """Block comment current line or selection."""
+        comline = self.__blockcomment_bar() + self.get_line_separator()
+        cursor = self.textCursor()
+        if self.has_selected_text():
+            self.extend_selection_to_complete_lines()
+            start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
+        else:
+            start_pos = end_pos = cursor.position()
+        cursor.beginEditBlock()
+        cursor.setPosition(start_pos)
+        cursor.movePosition(QTextCursor.StartOfBlock)
+        while cursor.position() <= end_pos:
+            cursor.insertText(self.comment_string + " ")
+            cursor.movePosition(QTextCursor.EndOfBlock)
+            if cursor.atEnd():
+                break
+            cursor.movePosition(QTextCursor.NextBlock)
+            end_pos += len(self.comment_string + " ")
+        cursor.setPosition(end_pos)
+        cursor.movePosition(QTextCursor.EndOfBlock)
+        if cursor.atEnd():
+            cursor.insertText(self.get_line_separator())
+        else:
+            cursor.movePosition(QTextCursor.NextBlock)
+        cursor.insertText(comline)
+        cursor.setPosition(start_pos)
+        cursor.movePosition(QTextCursor.StartOfBlock)
+        cursor.insertText(comline)
+        cursor.endEditBlock()
+
+    def unblockcomment(self):
+        """Un-block comment current line or selection."""
+        # Needed for backward compatibility with Spyder previous blockcomments.
+        # See spyder-ide/spyder#2845.
+        unblockcomment = self.__unblockcomment()
+        if not unblockcomment:
+            unblockcomment =  self.__unblockcomment(compatibility=True)
+        else:
+            return unblockcomment
+
+    def __unblockcomment(self, compatibility=False):
+        """Un-block comment current line or selection helper."""
+        def __is_comment_bar(cursor):
+            return to_text_string(cursor.block().text()
+                           ).startswith(
+                         self.__blockcomment_bar(compatibility=compatibility))
+        # Finding first comment bar
+        cursor1 = self.textCursor()
+        if __is_comment_bar(cursor1):
+            return
+        while not __is_comment_bar(cursor1):
+            cursor1.movePosition(QTextCursor.PreviousBlock)
+            if cursor1.blockNumber() == 0:
+                break
+        if not __is_comment_bar(cursor1):
+            return False
+
+        def __in_block_comment(cursor):
+            cs = self.comment_string
+            return to_text_string(cursor.block().text()).startswith(cs)
+        # Finding second comment bar
+        cursor2 = QTextCursor(cursor1)
+        cursor2.movePosition(QTextCursor.NextBlock)
+        while not __is_comment_bar(cursor2) and __in_block_comment(cursor2):
+            cursor2.movePosition(QTextCursor.NextBlock)
+            if cursor2.block() == self.document().lastBlock():
+                break
+        if not __is_comment_bar(cursor2):
+            return False
+        # Removing block comment
+        cursor3 = self.textCursor()
+        cursor3.beginEditBlock()
+        cursor3.setPosition(cursor1.position())
+        cursor3.movePosition(QTextCursor.NextBlock)
+        while cursor3.position() < cursor2.position():
+            cursor3.movePosition(QTextCursor.NextCharacter,
+                                 QTextCursor.KeepAnchor)
+            if not cursor3.atBlockEnd():
+                # standard commenting inserts '# ' but a trailing space on an
+                # empty line might be stripped.
+                if not compatibility:
+                    cursor3.movePosition(QTextCursor.NextCharacter,
+                                         QTextCursor.KeepAnchor)
+            cursor3.removeSelectedText()
+            cursor3.movePosition(QTextCursor.NextBlock)
+        for cursor in (cursor2, cursor1):
+            cursor3.setPosition(cursor.position())
+            cursor3.select(QTextCursor.BlockUnderCursor)
+            cursor3.removeSelectedText()
+        cursor3.endEditBlock()
+        return True
+
+    def create_new_cell(self):
+        firstline = '# %%' + self.get_line_separator()
+        endline = self.get_line_separator()
+        cursor = self.textCursor()
+        if self.has_selected_text():
+            self.extend_selection_to_complete_lines()
+            start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
+            endline = self.get_line_separator() + '# %%'
+        else:
+            start_pos = end_pos = cursor.position()
+
+        # Add cell comment or enclose current selection in cells
+        cursor.beginEditBlock()
+        cursor.setPosition(end_pos)
+        cursor.movePosition(QTextCursor.EndOfBlock)
+        cursor.insertText(endline)
+        cursor.setPosition(start_pos)
+        cursor.movePosition(QTextCursor.StartOfBlock)
+        cursor.insertText(firstline)
+        cursor.endEditBlock()
+        
+    # ---- Kill ring handlers
+    # Taken from Jupyter's QtConsole
+    # Copyright (c) 2001-2015, IPython Development Team
+    # Copyright (c) 2015-, Jupyter Development Team
+    # -------------------------------------------------------------------------
+    def kill_line_end(self):
+        """Kill the text on the current line from the cursor forward"""
+        cursor = self.textCursor()
+        cursor.clearSelection()
+        cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
+        if not cursor.hasSelection():
+            # Line deletion
+            cursor.movePosition(QTextCursor.NextBlock,
+                                QTextCursor.KeepAnchor)
+        self._kill_ring.kill_cursor(cursor)
+        self.setTextCursor(cursor)
+
+    def kill_line_start(self):
+        """Kill the text on the current line from the cursor backward"""
+        cursor = self.textCursor()
+        cursor.clearSelection()
+        cursor.movePosition(QTextCursor.StartOfBlock,
+                            QTextCursor.KeepAnchor)
+        self._kill_ring.kill_cursor(cursor)
+        self.setTextCursor(cursor)
+
+    def _get_word_start_cursor(self, position):
+        """Find the start of the word to the left of the given position. If a
+           sequence of non-word characters precedes the first word, skip over
+           them. (This emulates the behavior of bash, emacs, etc.)
+        """
+        document = self.document()
+        position -= 1
+        while (position and not
+               self.is_letter_or_number(document.characterAt(position))):
+            position -= 1
+        while position and self.is_letter_or_number(
+                document.characterAt(position)):
+            position -= 1
+        cursor = self.textCursor()
+        cursor.setPosition(self.next_cursor_position())
+        return cursor
+
+    def _get_word_end_cursor(self, position):
+        """Find the end of the word to the right of the given position. If a
+           sequence of non-word characters precedes the first word, skip over
+           them. (This emulates the behavior of bash, emacs, etc.)
+        """
+        document = self.document()
+        cursor = self.textCursor()
+        position = cursor.position()
+        cursor.movePosition(QTextCursor.End)
+        end = cursor.position()
+        while (position < end and
+               not self.is_letter_or_number(document.characterAt(position))):
+            position = self.next_cursor_position(position)
+        while (position < end and
+               self.is_letter_or_number(document.characterAt(position))):
+            position = self.next_cursor_position(position)
+        cursor.setPosition(position)
+        return cursor
+
+    def kill_prev_word(self):
+        """Kill the previous word"""
+        position = self.textCursor().position()
+        cursor = self._get_word_start_cursor(position)
+        cursor.setPosition(position, QTextCursor.KeepAnchor)
+        self._kill_ring.kill_cursor(cursor)
+        self.setTextCursor(cursor)
+
+    def kill_next_word(self):
+        """Kill the next word"""
+        position = self.textCursor().position()
+        cursor = self._get_word_end_cursor(position)
+        cursor.setPosition(position, QTextCursor.KeepAnchor)
+        self._kill_ring.kill_cursor(cursor)
+        self.setTextCursor(cursor)
+
+    # ---- Autoinsertion of quotes/colons
+    # -------------------------------------------------------------------------
+    def __get_current_color(self, cursor=None):
+        """Get the syntax highlighting color for the current cursor position"""
+        if cursor is None:
+            cursor = self.textCursor()
+
+        block = cursor.block()
+        pos = cursor.position() - block.position()  # relative pos within block
+        layout = block.layout()
+        block_formats = layout.formats()
+
+        if block_formats:
+            # To easily grab current format for autoinsert_colons
+            if cursor.atBlockEnd():
+                current_format = block_formats[-1].format
+            else:
+                current_format = None
+                for fmt in block_formats:
+                    if (pos >= fmt.start) and (pos < fmt.start + fmt.length):
+                        current_format = fmt.format
+                if current_format is None:
+                    return None
+            color = current_format.foreground().color().name()
+            return color
+        else:
+            return None
+
+    def in_comment_or_string(self, cursor=None, position=None):
+        """Is the cursor or position inside or next to a comment or string?
+
+        If *cursor* is None, *position* is used instead. If *position* is also
+        None, then the current cursor position is used.
+        """
+        if self.highlighter:
+            if cursor is None:
+                cursor = self.textCursor()
+                if position:
+                    cursor.setPosition(position)
+            current_color = self.__get_current_color(cursor=cursor)
+
+            comment_color = self.highlighter.get_color_name('comment')
+            string_color = self.highlighter.get_color_name('string')
+            if (current_color == comment_color) or (current_color == string_color):
+                return True
+            else:
+                return False
+        else:
+            return False
+
+    def __colon_keyword(self, text):
+        stmt_kws = ['def', 'for', 'if', 'while', 'with', 'class', 'elif',
+                    'except']
+        whole_kws = ['else', 'try', 'except', 'finally']
+        text = text.lstrip()
+        words = text.split()
+        if any([text == wk for wk in whole_kws]):
+            return True
+        elif len(words) < 2:
+            return False
+        elif any([words[0] == sk for sk in stmt_kws]):
+            return True
+        else:
+            return False
+
+    def __forbidden_colon_end_char(self, text):
+        end_chars = [':', '\\', '[', '{', '(', ',']
+        text = text.rstrip()
+        if any([text.endswith(c) for c in end_chars]):
+            return True
+        else:
+            return False
+
+    def __has_colon_not_in_brackets(self, text):
+        """
+        Return whether a string has a colon which is not between brackets.
+        This function returns True if the given string has a colon which is
+        not between a pair of (round, square or curly) brackets. It assumes
+        that the brackets in the string are balanced.
+        """
+        bracket_ext = self.editor_extensions.get(CloseBracketsExtension)
+        for pos, char in enumerate(text):
+            if (char == ':' and
+                    not bracket_ext.unmatched_brackets_in_line(text[:pos])):
+                return True
+        return False
+
+    def __has_unmatched_opening_bracket(self):
+        """
+        Checks if there are any unmatched opening brackets before the current
+        cursor position.
+        """
+        position = self.textCursor().position()
+        for brace in [']', ')', '}']:
+            match = self.find_brace_match(position, brace, forward=False)
+            if match is not None:
+                return True
+        return False
+
+    def autoinsert_colons(self):
+        """Decide if we want to autoinsert colons"""
+        bracket_ext = self.editor_extensions.get(CloseBracketsExtension)
+        self.completion_widget.hide()
+        line_text = self.get_text('sol', 'cursor')
+        if not self.textCursor().atBlockEnd():
+            return False
+        elif self.in_comment_or_string():
+            return False
+        elif not self.__colon_keyword(line_text):
+            return False
+        elif self.__forbidden_colon_end_char(line_text):
+            return False
+        elif bracket_ext.unmatched_brackets_in_line(line_text):
+            return False
+        elif self.__has_colon_not_in_brackets(line_text):
+            return False
+        elif self.__has_unmatched_opening_bracket():
+            return False
+        else:
+            return True
+
+    def next_char(self):
+        cursor = self.textCursor()
+        cursor.movePosition(QTextCursor.NextCharacter,
+                            QTextCursor.KeepAnchor)
+        next_char = to_text_string(cursor.selectedText())
+        return next_char
+
+    def in_comment(self, cursor=None, position=None):
+        """Returns True if the given position is inside a comment.
+
+        Parameters
+        ----------
+        cursor : QTextCursor, optional
+            The position to check.
+        position : int, optional
+            The position to check if *cursor* is None. This parameter
+            is ignored when *cursor* is not None.
+
+        If both *cursor* and *position* are none, then the position returned
+        by self.textCursor() is used instead.
+        """
+        if self.highlighter:
+            if cursor is None:
+                cursor = self.textCursor()
+                if position is not None:
+                    cursor.setPosition(position)
+            current_color = self.__get_current_color(cursor)
+            comment_color = self.highlighter.get_color_name('comment')
+            return (current_color == comment_color)
+        else:
+            return False
+
+    def in_string(self, cursor=None, position=None):
+        """Returns True if the given position is inside a string.
+
+        Parameters
+        ----------
+        cursor : QTextCursor, optional
+            The position to check.
+        position : int, optional
+            The position to check if *cursor* is None. This parameter
+            is ignored when *cursor* is not None.
+
+        If both *cursor* and *position* are none, then the position returned
+        by self.textCursor() is used instead.
+        """
+        if self.highlighter:
+            if cursor is None:
+                cursor = self.textCursor()
+                if position is not None:
+                    cursor.setPosition(position)
+            current_color = self.__get_current_color(cursor)
+            string_color = self.highlighter.get_color_name('string')
+            return (current_color == string_color)
+        else:
+            return False
+
+    # ---- Qt Event handlers
+    # -------------------------------------------------------------------------
+    def setup_context_menu(self):
+        """Setup context menu"""
+        self.undo_action = create_action(
+            self, _("Undo"), icon=ima.icon('undo'),
+            shortcut=self.get_shortcut('undo'), triggered=self.undo)
+        self.redo_action = create_action(
+            self, _("Redo"), icon=ima.icon('redo'),
+            shortcut=self.get_shortcut('redo'), triggered=self.redo)
+        self.cut_action = create_action(
+            self, _("Cut"), icon=ima.icon('editcut'),
+            shortcut=self.get_shortcut('cut'), triggered=self.cut)
+        self.copy_action = create_action(
+            self, _("Copy"), icon=ima.icon('editcopy'),
+            shortcut=self.get_shortcut('copy'), triggered=self.copy)
+        self.paste_action = create_action(
+            self, _("Paste"), icon=ima.icon('editpaste'),
+            shortcut=self.get_shortcut('paste'),
+            triggered=self.paste)
+        selectall_action = create_action(
+            self, _("Select All"), icon=ima.icon('selectall'),
+            shortcut=self.get_shortcut('select all'),
+            triggered=self.selectAll)
+        toggle_comment_action = create_action(
+            self, _("Comment")+"/"+_("Uncomment"), icon=ima.icon('comment'),
+            shortcut=self.get_shortcut('toggle comment'),
+            triggered=self.toggle_comment)
+        self.clear_all_output_action = create_action(
+            self, _("Clear all ouput"), icon=ima.icon('ipython_console'),
+            triggered=self.clear_all_output)
+        self.ipynb_convert_action = create_action(
+            self, _("Convert to Python file"), icon=ima.icon('python'),
+            triggered=self.convert_notebook)
+        self.gotodef_action = create_action(
+            self, _("Go to definition"),
+            shortcut=self.get_shortcut('go to definition'),
+            triggered=self.go_to_definition_from_cursor)
+
+        self.inspect_current_object_action = create_action(
+            self, _("Inspect current object"),
+            icon=ima.icon('MessageBoxInformation'),
+            shortcut=self.get_shortcut('inspect current object'),
+            triggered=self.sig_show_object_info)
+
+        # Run actions
+
+        # Zoom actions
+        zoom_in_action = create_action(
+            self, _("Zoom in"), icon=ima.icon('zoom_in'),
+            shortcut=QKeySequence(QKeySequence.ZoomIn),
+            triggered=self.zoom_in)
+        zoom_out_action = create_action(
+            self, _("Zoom out"), icon=ima.icon('zoom_out'),
+            shortcut=QKeySequence(QKeySequence.ZoomOut),
+            triggered=self.zoom_out)
+        zoom_reset_action = create_action(
+            self, _("Zoom reset"), shortcut=QKeySequence("Ctrl+0"),
+            triggered=self.zoom_reset)
+
+        # Docstring
+        writer = self.writer_docstring
+        self.docstring_action = create_action(
+            self, _("Generate docstring"),
+            shortcut=self.get_shortcut('docstring'),
+            triggered=writer.write_docstring_at_first_line_of_function)
+
+        # Document formatting
+        formatter = self.get_conf(
+            ('provider_configuration', 'lsp', 'values', 'formatting'),
+            default='',
+            section='completions',
+        )
+        self.format_action = create_action(
+            self,
+            _('Format file or selection with {0}').format(
+                formatter.capitalize()),
+            shortcut=self.get_shortcut('autoformatting'),
+            triggered=self.format_document_or_range)
+
+        self.format_action.setEnabled(False)
+
+        # Build menu
+        # TODO: Change to SpyderMenu when the editor is migrated to the new
+        # API
+        self.menu = QMenu(self)
+        actions_1 = [self.gotodef_action, self.inspect_current_object_action,
+                     None, self.undo_action, self.redo_action, None,
+                     self.cut_action, self.copy_action,
+                     self.paste_action, selectall_action]
+        actions_2 = [None, zoom_in_action, zoom_out_action, zoom_reset_action,
+                     None, toggle_comment_action, self.docstring_action,
+                     self.format_action]
+        if nbformat is not None:
+            nb_actions = [self.clear_all_output_action,
+                          self.ipynb_convert_action, None]
+            actions = actions_1 + nb_actions + actions_2
+            add_actions(self.menu, actions)
+        else:
+            actions = actions_1 + actions_2
+            add_actions(self.menu, actions)
+
+        # Read-only context-menu
+        # TODO: Change to SpyderMenu when the editor is migrated to the new
+        # API
+        self.readonly_menu = QMenu(self)
+        add_actions(self.readonly_menu,
+                    (self.copy_action, None, selectall_action,
+                     self.gotodef_action))
+
+    def keyReleaseEvent(self, event):
+        """Override Qt method."""
+        self.sig_key_released.emit(event)
+        key = event.key()
+        direction_keys = {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down}
+        if key in direction_keys:
+            self.request_cursor_event()
+
+        # Update decorations after releasing these keys because they don't
+        # trigger the emission of the valueChanged signal in
+        # verticalScrollBar.
+        # See https://bugreports.qt.io/browse/QTBUG-25365
+        if key in {Qt.Key_Up,  Qt.Key_Down}:
+            self.update_decorations_timer.start()
+
+        # This necessary to run our Pygments highlighter again after the
+        # user generated text changes
+        if event.text():
+            # Stop the active timer and start it again to not run it on
+            # every event
+            if self.timer_syntax_highlight.isActive():
+                self.timer_syntax_highlight.stop()
+
+            # Adjust interval to rehighlight according to the lines
+            # present in the file
+            total_lines = self.get_line_count()
+            if total_lines < 1000:
+                self.timer_syntax_highlight.setInterval(600)
+            elif total_lines < 2000:
+                self.timer_syntax_highlight.setInterval(800)
+            else:
+                self.timer_syntax_highlight.setInterval(1000)
+            self.timer_syntax_highlight.start()
+
+        self._restore_editor_cursor_and_selections()
+        super(CodeEditor, self).keyReleaseEvent(event)
+        event.ignore()
+
+    def event(self, event):
+        """Qt method override."""
+        if event.type() == QEvent.ShortcutOverride:
+            event.ignore()
+            return False
+        else:
+            return super(CodeEditor, self).event(event)
+
+    def _handle_keypress_event(self, event):
+        """Handle keypress events."""
+        TextEditBaseWidget.keyPressEvent(self, event)
+
+        # Trigger the following actions only if the event generates
+        # a text change.
+        text = to_text_string(event.text())
+        if text:
+            # The next three lines are a workaround for a quirk of
+            # QTextEdit on Linux with Qt < 5.15, MacOs and Windows.
+            # See spyder-ide/spyder#12663 and
+            # https://bugreports.qt.io/browse/QTBUG-35861
+            if (
+                parse(QT_VERSION) < parse('5.15')
+                or os.name == 'nt' or sys.platform == 'darwin'
+            ):
+                cursor = self.textCursor()
+                cursor.setPosition(cursor.position())
+                self.setTextCursor(cursor)
+            self.sig_text_was_inserted.emit()
+
+    def keyPressEvent(self, event):
+        """Reimplement Qt method."""
+        if self.completions_hint_after_ms > 0:
+            self._completions_hint_idle = False
+            self._timer_completions_hint.start(self.completions_hint_after_ms)
+        else:
+            self._set_completions_hint_idle()
+
+        # Send the signal to the editor's extension.
+        event.ignore()
+        self.sig_key_pressed.emit(event)
+
+        self._last_pressed_key = key = event.key()
+        self._last_key_pressed_text = text = to_text_string(event.text())
+        has_selection = self.has_selected_text()
+        ctrl = event.modifiers() & Qt.ControlModifier
+        shift = event.modifiers() & Qt.ShiftModifier
+
+        if text:
+            self.clear_occurrences()
+
+
+        if key in {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down}:
+            self.hide_tooltip()
+
+        if event.isAccepted():
+            # The event was handled by one of the editor extension.
+            return
+
+        if key in [Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt,
+                   Qt.Key_Meta, Qt.KeypadModifier]:
+            # The user pressed only a modifier key.
+            if ctrl:
+                pos = self.mapFromGlobal(QCursor.pos())
+                pos = self.calculate_real_position_from_global(pos)
+                if self._handle_goto_uri_event(pos):
+                    event.accept()
+                    return
+
+                if self._handle_goto_definition_event(pos):
+                    event.accept()
+                    return
+            return
+
+        # ---- Handle hard coded and builtin actions
+        operators = {'+', '-', '*', '**', '/', '//', '%', '@', '<<', '>>',
+                     '&', '|', '^', '~', '<', '>', '<=', '>=', '==', '!='}
+        delimiters = {',', ':', ';', '@', '=', '->', '+=', '-=', '*=', '/=',
+                      '//=', '%=', '@=', '&=', '|=', '^=', '>>=', '<<=', '**='}
+
+        if text not in self.auto_completion_characters:
+            if text in operators or text in delimiters:
+                self.completion_widget.hide()
+        if key in (Qt.Key_Enter, Qt.Key_Return):
+            if not shift and not ctrl:
+                if (
+                    self.add_colons_enabled and
+                    self.is_python_like() and
+                    self.autoinsert_colons()
+                ):
+                    self.textCursor().beginEditBlock()
+                    self.insert_text(':' + self.get_line_separator())
+                    if self.strip_trailing_spaces_on_modify:
+                        self.fix_and_strip_indent()
+                    else:
+                        self.fix_indent()
+                    self.textCursor().endEditBlock()
+                elif self.is_completion_widget_visible():
+                    self.select_completion_list()
+                else:
+                    self.textCursor().beginEditBlock()
+                    cur_indent = self.get_block_indentation(
+                        self.textCursor().blockNumber())
+                    self._handle_keypress_event(event)
+                    # Check if we're in a comment or a string at the
+                    # current position
+                    cmt_or_str_cursor = self.in_comment_or_string()
+
+                    # Check if the line start with a comment or string
+                    cursor = self.textCursor()
+                    cursor.setPosition(cursor.block().position(),
+                                       QTextCursor.KeepAnchor)
+                    cmt_or_str_line_begin = self.in_comment_or_string(
+                        cursor=cursor)
+
+                    # Check if we are in a comment or a string
+                    cmt_or_str = cmt_or_str_cursor and cmt_or_str_line_begin
+
+                    if self.strip_trailing_spaces_on_modify:
+                        self.fix_and_strip_indent(
+                            comment_or_string=cmt_or_str,
+                            cur_indent=cur_indent)
+                    else:
+                        self.fix_indent(comment_or_string=cmt_or_str,
+                                        cur_indent=cur_indent)
+                    self.textCursor().endEditBlock()
+        elif key == Qt.Key_Insert and not shift and not ctrl:
+            self.setOverwriteMode(not self.overwriteMode())
+        elif key == Qt.Key_Backspace and not shift and not ctrl:
+            if has_selection or not self.intelligent_backspace:
+                self._handle_keypress_event(event)
+            else:
+                leading_text = self.get_text('sol', 'cursor')
+                leading_length = len(leading_text)
+                trailing_spaces = leading_length - len(leading_text.rstrip())
+                trailing_text = self.get_text('cursor', 'eol')
+                matches = ('()', '[]', '{}', '\'\'', '""')
+                if (
+                    not leading_text.strip() and
+                    (leading_length > len(self.indent_chars))
+                ):
+                    if leading_length % len(self.indent_chars) == 0:
+                        self.unindent()
+                    else:
+                        self._handle_keypress_event(event)
+                elif trailing_spaces and not trailing_text.strip():
+                    self.remove_suffix(leading_text[-trailing_spaces:])
+                elif (
+                    leading_text and
+                    trailing_text and
+                    (leading_text[-1] + trailing_text[0] in matches)
+                ):
+                    cursor = self.textCursor()
+                    cursor.movePosition(QTextCursor.PreviousCharacter)
+                    cursor.movePosition(QTextCursor.NextCharacter,
+                                        QTextCursor.KeepAnchor, 2)
+                    cursor.removeSelectedText()
+                else:
+                    self._handle_keypress_event(event)
+        elif key == Qt.Key_Home:
+            self.stdkey_home(shift, ctrl)
+        elif key == Qt.Key_End:
+            # See spyder-ide/spyder#495: on MacOS X, it is necessary to
+            # redefine this basic action which should have been implemented
+            # natively
+            self.stdkey_end(shift, ctrl)
+        elif (
+            text in self.auto_completion_characters and
+            self.automatic_completions
+        ):
+            self.insert_text(text)
+            if text == ".":
+                if not self.in_comment_or_string():
+                    text = self.get_text('sol', 'cursor')
+                    last_obj = getobj(text)
+                    prev_char = text[-2] if len(text) > 1 else ''
+                    if (
+                        prev_char in {')', ']', '}'} or
+                        (last_obj and not last_obj.isdigit())
+                    ):
+                        # Completions should be triggered immediately when
+                        # an autocompletion character is introduced.
+                        self.do_completion(automatic=True)
+            else:
+                self.do_completion(automatic=True)
+        elif (
+            text in self.signature_completion_characters and
+            not self.has_selected_text()
+        ):
+            self.insert_text(text)
+            self.request_signature()
+        elif (
+            key == Qt.Key_Colon and
+            not has_selection and
+            self.auto_unindent_enabled
+        ):
+            leading_text = self.get_text('sol', 'cursor')
+            if leading_text.lstrip() in ('else', 'finally'):
+                ind = lambda txt: len(txt) - len(txt.lstrip())
+                prevtxt = (to_text_string(self.textCursor().block().
+                           previous().text()))
+                if self.language == 'Python':
+                    prevtxt = prevtxt.rstrip()
+                if ind(leading_text) == ind(prevtxt):
+                    self.unindent(force=True)
+            self._handle_keypress_event(event)
+        elif (
+            key == Qt.Key_Space and
+            not shift and
+            not ctrl and
+            not has_selection and
+            self.auto_unindent_enabled
+        ):
+            self.completion_widget.hide()
+            leading_text = self.get_text('sol', 'cursor')
+            if leading_text.lstrip() in ('elif', 'except'):
+                ind = lambda txt: len(txt)-len(txt.lstrip())
+                prevtxt = (to_text_string(self.textCursor().block().
+                           previous().text()))
+                if self.language == 'Python':
+                    prevtxt = prevtxt.rstrip()
+                if ind(leading_text) == ind(prevtxt):
+                    self.unindent(force=True)
+            self._handle_keypress_event(event)
+        elif key == Qt.Key_Tab and not ctrl:
+            # Important note: <TAB> can't be called with a QShortcut because
+            # of its singular role with respect to widget focus management
+            if not has_selection and not self.tab_mode:
+                self.intelligent_tab()
+            else:
+                # indent the selected text
+                self.indent_or_replace()
+        elif key == Qt.Key_Backtab and not ctrl:
+            # Backtab, i.e. Shift+<TAB>, could be treated as a QShortcut but
+            # there is no point since <TAB> can't (see above)
+            if not has_selection and not self.tab_mode:
+                self.intelligent_backtab()
+            else:
+                # indent the selected text
+                self.unindent()
+            event.accept()
+        elif not event.isAccepted():
+            self._handle_keypress_event(event)
+
+        if not event.modifiers():
+            # Accept event to avoid it being handled by the parent.
+            # Modifiers should be passed to the parent because they
+            # could be shortcuts
+            event.accept()
+
+    def do_automatic_completions(self):
+        """Perform on the fly completions."""
+        if not self.automatic_completions:
+            return
+
+        cursor = self.textCursor()
+        pos = cursor.position()
+        cursor.select(QTextCursor.WordUnderCursor)
+        text = to_text_string(cursor.selectedText())
+
+        key = self._last_pressed_key
+        if key is not None:
+            if key in [Qt.Key_Return, Qt.Key_Escape,
+                       Qt.Key_Tab, Qt.Key_Backtab, Qt.Key_Space]:
+                self._last_pressed_key = None
+                return
+
+        # Correctly handle completions when Backspace key is pressed.
+        # We should not show the widget if deleting a space before a word.
+        if key == Qt.Key_Backspace:
+            cursor.setPosition(max(0, pos - 1), QTextCursor.MoveAnchor)
+            cursor.select(QTextCursor.WordUnderCursor)
+            prev_text = to_text_string(cursor.selectedText())
+            cursor.setPosition(max(0, pos - 1), QTextCursor.MoveAnchor)
+            cursor.setPosition(pos, QTextCursor.KeepAnchor)
+            prev_char = cursor.selectedText()
+            if prev_text == '' or prev_char in (u'\u2029', ' ', '\t'):
+                return
+
+        # Text might be after a dot '.'
+        if text == '':
+            cursor.setPosition(max(0, pos - 1), QTextCursor.MoveAnchor)
+            cursor.select(QTextCursor.WordUnderCursor)
+            text = to_text_string(cursor.selectedText())
+            if text != '.':
+                text = ''
+
+        # WordUnderCursor fails if the cursor is next to a right brace.
+        # If the returned text starts with it, we move to the left.
+        if text.startswith((')', ']', '}')):
+            cursor.setPosition(pos - 1, QTextCursor.MoveAnchor)
+            cursor.select(QTextCursor.WordUnderCursor)
+            text = to_text_string(cursor.selectedText())
+
+        is_backspace = (
+            self.is_completion_widget_visible() and key == Qt.Key_Backspace)
+
+        if (
+            (len(text) >= self.automatic_completions_after_chars) and
+            self._last_key_pressed_text or
+            is_backspace
+        ):
+            # Perform completion on the fly
+            if not self.in_comment_or_string():
+                # Variables can include numbers and underscores
+                if (
+                    text.isalpha() or
+                    text.isalnum() or
+                    '_' in text
+                    or '.' in text
+                ):
+                    self.do_completion(automatic=True)
+                    self._last_key_pressed_text = ''
+                    self._last_pressed_key = None
+
+    def fix_and_strip_indent(self, *args, **kwargs):
+        """
+        Automatically fix indent and strip previous automatic indent.
+
+        args and kwargs are forwarded to self.fix_indent
+        """
+        # Fix indent
+        cursor_before = self.textCursor().position()
+        # A change just occurred on the last line (return was pressed)
+        if cursor_before > 0:
+            self.last_change_position = cursor_before - 1
+        self.fix_indent(*args, **kwargs)
+        cursor_after = self.textCursor().position()
+        # Remove previous spaces and update last_auto_indent
+        nspaces_removed = self.strip_trailing_spaces()
+        self.last_auto_indent = (cursor_before - nspaces_removed,
+                                 cursor_after - nspaces_removed)
+
+    def run_pygments_highlighter(self):
+        """Run pygments highlighter."""
+        if isinstance(self.highlighter, sh.PygmentsSH):
+            self.highlighter.make_charlist()
+
+    def get_pattern_at(self, coordinates):
+        """
+        Return key, text and cursor for pattern (if found at coordinates).
+        """
+        return self.get_pattern_cursor_at(self.highlighter.patterns,
+                                          coordinates)
+
+    def get_pattern_cursor_at(self, pattern, coordinates):
+        """
+        Find pattern located at the line where the coordinate is located.
+
+        This returns the actual match and the cursor that selects the text.
+        """
+        cursor, key, text = None, None, None
+        break_loop = False
+
+        # Check if the pattern is in line
+        line = self.get_line_at(coordinates)
+
+        for match in pattern.finditer(line):
+            for key, value in list(match.groupdict().items()):
+                if value:
+                    start, end = sh.get_span(match)
+
+                    # Get cursor selection if pattern found
+                    cursor = self.cursorForPosition(coordinates)
+                    cursor.movePosition(QTextCursor.StartOfBlock)
+                    line_start_position = cursor.position()
+
+                    cursor.setPosition(line_start_position + start,
+                                       cursor.MoveAnchor)
+                    start_rect = self.cursorRect(cursor)
+                    cursor.setPosition(line_start_position + end,
+                                       cursor.MoveAnchor)
+                    end_rect = self.cursorRect(cursor)
+                    bounding_rect = start_rect.united(end_rect)
+
+                    # Check coordinates are located within the selection rect
+                    if bounding_rect.contains(coordinates):
+                        text = line[start:end]
+                        cursor.setPosition(line_start_position + start,
+                                           cursor.KeepAnchor)
+                        break_loop = True
+                        break
+
+            if break_loop:
+                break
+
+        return key, text, cursor
+
+    def _preprocess_file_uri(self, uri):
+        """Format uri to conform to absolute or relative file paths."""
+        fname = uri.replace('file://', '')
+        if fname[-1] == '/':
+            fname = fname[:-1]
+
+        # ^/ is used to denote the current project root
+        if fname.startswith("^/"):
+            if self.current_project_path is not None:
+                fname = osp.join(self.current_project_path, fname[2:])
+            else:
+                fname = fname.replace("^/", "~/")
+
+        if fname.startswith("~/"):
+            fname = osp.expanduser(fname)
+
+        dirname = osp.dirname(osp.abspath(self.filename))
+        if osp.isdir(dirname):
+            if not osp.isfile(fname):
+                # Maybe relative
+                fname = osp.join(dirname, fname)
+
+        self.sig_file_uri_preprocessed.emit(fname)
+
+        return fname
+
+    def _handle_goto_definition_event(self, pos):
+        """Check if goto definition can be applied and apply highlight."""
+        text = self.get_word_at(pos)
+        if text and not sourcecode.is_keyword(to_text_string(text)):
+            if not self.__cursor_changed:
+                QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor))
+                self.__cursor_changed = True
+            cursor = self.cursorForPosition(pos)
+            cursor.select(QTextCursor.WordUnderCursor)
+            self.clear_extra_selections('ctrl_click')
+            self.highlight_selection(
+                'ctrl_click', cursor,
+                foreground_color=self.ctrl_click_color,
+                underline_color=self.ctrl_click_color,
+                underline_style=QTextCharFormat.SingleUnderline)
+            return True
+        else:
+            return False
+
+    def _handle_goto_uri_event(self, pos):
+        """Check if go to uri can be applied and apply highlight."""
+        key, pattern_text, cursor = self.get_pattern_at(pos)
+        if key and pattern_text and cursor:
+            self._last_hover_pattern_key = key
+            self._last_hover_pattern_text = pattern_text
+
+            color = self.ctrl_click_color
+
+            if key in ['file']:
+                fname = self._preprocess_file_uri(pattern_text)
+                if not osp.isfile(fname):
+                    color = QColor(SpyderPalette.COLOR_ERROR_2)
+
+            self.clear_extra_selections('ctrl_click')
+            self.highlight_selection(
+                'ctrl_click', cursor,
+                foreground_color=color,
+                underline_color=color,
+                underline_style=QTextCharFormat.SingleUnderline)
+
+            if not self.__cursor_changed:
+                QApplication.setOverrideCursor(
+                    QCursor(Qt.PointingHandCursor))
+                self.__cursor_changed = True
+
+            self.sig_uri_found.emit(pattern_text)
+            return True
+        else:
+            self._last_hover_pattern_key = key
+            self._last_hover_pattern_text = pattern_text
+            return False
+
+    def go_to_uri_from_cursor(self, uri):
+        """Go to url from cursor and defined hover patterns."""
+        key = self._last_hover_pattern_key
+        full_uri = uri
+
+        if key in ['file']:
+            fname = self._preprocess_file_uri(uri)
+
+            if osp.isfile(fname) and encoding.is_text_file(fname):
+                # Open in editor
+                self.go_to_definition.emit(fname, 0, 0)
+            else:
+                # Use external program
+                fname = file_uri(fname)
+                start_file(fname)
+        elif key in ['mail', 'url']:
+            if '@' in uri and not uri.startswith('mailto:'):
+                full_uri = 'mailto:' + uri
+            quri = QUrl(full_uri)
+            QDesktopServices.openUrl(quri)
+        elif key in ['issue']:
+            # Issue URI
+            repo_url = uri.replace('#', '/issues/')
+            if uri.startswith(('gh-', 'bb-', 'gl-')):
+                number = uri[3:]
+                remotes = get_git_remotes(self.filename)
+                remote = remotes.get('upstream', remotes.get('origin'))
+                if remote:
+                    full_uri = remote_to_url(remote) + '/issues/' + number
+                else:
+                    full_uri = None
+            elif uri.startswith('gh:') or ':' not in uri:
+                # Github
+                repo_and_issue = repo_url
+                if uri.startswith('gh:'):
+                    repo_and_issue = repo_url[3:]
+                full_uri = 'https://github.com/' + repo_and_issue
+            elif uri.startswith('gl:'):
+                # Gitlab
+                full_uri = 'https://gitlab.com/' + repo_url[3:]
+            elif uri.startswith('bb:'):
+                # Bitbucket
+                full_uri = 'https://bitbucket.org/' + repo_url[3:]
+
+            if full_uri:
+                quri = QUrl(full_uri)
+                QDesktopServices.openUrl(quri)
+            else:
+                QMessageBox.information(
+                    self,
+                    _('Information'),
+                    _('This file is not part of a local repository or '
+                      'upstream/origin remotes are not defined!'),
+                    QMessageBox.Ok,
+                )
+        self.hide_tooltip()
+        return full_uri
+
+    def line_range(self, position):
+        """
+        Get line range from position.
+        """
+        if position is None:
+            return None
+        if position >= self.document().characterCount():
+            return None
+        # Check if still on the line
+        cursor = self.textCursor()
+        cursor.setPosition(position)
+        line_range = (cursor.block().position(),
+                      cursor.block().position()
+                      + cursor.block().length() - 1)
+        return line_range
+
+    def strip_trailing_spaces(self):
+        """
+        Strip trailing spaces if needed.
+
+        Remove trailing whitespace on leaving a non-string line containing it.
+        Return the number of removed spaces.
+        """
+        if not running_under_pytest():
+            if not self.hasFocus():
+                # Avoid problem when using split editor
+                return 0
+        # Update current position
+        current_position = self.textCursor().position()
+        last_position = self.last_position
+        self.last_position = current_position
+
+        if self.skip_rstrip:
+            return 0
+
+        line_range = self.line_range(last_position)
+        if line_range is None:
+            # Doesn't apply
+            return 0
+
+        def pos_in_line(pos):
+            """Check if pos is in last line."""
+            if pos is None:
+                return False
+            return line_range[0] <= pos <= line_range[1]
+
+        if pos_in_line(current_position):
+            # Check if still on the line
+            return 0
+
+        # Check if end of line in string
+        cursor = self.textCursor()
+        cursor.setPosition(line_range[1])
+
+        if (not self.strip_trailing_spaces_on_modify
+                or self.in_string(cursor=cursor)):
+            if self.last_auto_indent is None:
+                return 0
+            elif (self.last_auto_indent !=
+                  self.line_range(self.last_auto_indent[0])):
+                # line not empty
+                self.last_auto_indent = None
+                return 0
+            line_range = self.last_auto_indent
+            self.last_auto_indent = None
+        elif not pos_in_line(self.last_change_position):
+            # Should process if pressed return or made a change on the line:
+            return 0
+
+        cursor.setPosition(line_range[0])
+        cursor.setPosition(line_range[1],
+                           QTextCursor.KeepAnchor)
+        # remove spaces on the right
+        text = cursor.selectedText()
+        strip = text.rstrip()
+        # I think all the characters we can strip are in a single QChar.
+        # Therefore there shouldn't be any length problems.
+        N_strip = qstring_length(text[len(strip):])
+
+        if N_strip > 0:
+            # Select text to remove
+            cursor.setPosition(line_range[1] - N_strip)
+            cursor.setPosition(line_range[1],
+                               QTextCursor.KeepAnchor)
+            cursor.removeSelectedText()
+            # Correct last change position
+            self.last_change_position = line_range[1]
+            self.last_position = self.textCursor().position()
+            return N_strip
+        return 0
+
+    def move_line_up(self):
+        """Move up current line or selected text"""
+        self.__move_line_or_selection(after_current_line=False)
+
+    def move_line_down(self):
+        """Move down current line or selected text"""
+        self.__move_line_or_selection(after_current_line=True)
+
+    def __move_line_or_selection(self, after_current_line=True):
+        cursor = self.textCursor()
+        # Unfold any folded code block before moving lines up/down
+        folding_panel = self.panels.get('FoldingPanel')
+        fold_start_line = cursor.blockNumber() + 1
+        block = cursor.block().next()
+
+        if fold_start_line in folding_panel.folding_status:
+            fold_status = folding_panel.folding_status[fold_start_line]
+            if fold_status:
+                folding_panel.toggle_fold_trigger(block)
+
+        if after_current_line:
+            # Unfold any folded region when moving lines down
+            fold_start_line = cursor.blockNumber() + 2
+            block = cursor.block().next().next()
+
+            if fold_start_line in folding_panel.folding_status:
+                fold_status = folding_panel.folding_status[fold_start_line]
+                if fold_status:
+                    folding_panel.toggle_fold_trigger(block)
+        else:
+            # Unfold any folded region when moving lines up
+            block = cursor.block()
+            offset = 0
+            if self.has_selected_text():
+                ((selection_start, _),
+                 (selection_end)) = self.get_selection_start_end()
+                if selection_end != selection_start:
+                    offset = 1
+            fold_start_line = block.blockNumber() - 1 - offset
+
+            # Find the innermost code folding region for the current position
+            enclosing_regions = sorted(list(
+                folding_panel.current_tree[fold_start_line]))
+
+            folding_status = folding_panel.folding_status
+            if len(enclosing_regions) > 0:
+                for region in enclosing_regions:
+                    fold_start_line = region.begin
+                    block = self.document().findBlockByNumber(fold_start_line)
+                    if fold_start_line in folding_status:
+                        fold_status = folding_status[fold_start_line]
+                        if fold_status:
+                            folding_panel.toggle_fold_trigger(block)
+
+        self._TextEditBaseWidget__move_line_or_selection(
+            after_current_line=after_current_line)
+
+    def mouseMoveEvent(self, event):
+        """Underline words when pressing <CONTROL>"""
+        # Restart timer every time the mouse is moved
+        # This is needed to correctly handle hover hints with a delay
+        self._timer_mouse_moving.start()
+
+        pos = event.pos()
+        self._last_point = pos
+        alt = event.modifiers() & Qt.AltModifier
+        ctrl = event.modifiers() & Qt.ControlModifier
+
+        if alt:
+            self.sig_alt_mouse_moved.emit(event)
+            event.accept()
+            return
+
+        if ctrl:
+            if self._handle_goto_uri_event(pos):
+                event.accept()
+                return
+
+        if self.has_selected_text():
+            TextEditBaseWidget.mouseMoveEvent(self, event)
+            return
+
+        if self.go_to_definition_enabled and ctrl:
+            if self._handle_goto_definition_event(pos):
+                event.accept()
+                return
+
+        if self.__cursor_changed:
+            self._restore_editor_cursor_and_selections()
+        else:
+            if (not self._should_display_hover(pos)
+                    and not self.is_completion_widget_visible()):
+                self.hide_tooltip()
+
+        TextEditBaseWidget.mouseMoveEvent(self, event)
+
+    def setPlainText(self, txt):
+        """
+        Extends setPlainText to emit the new_text_set signal.
+
+        :param txt: The new text to set.
+        :param mime_type: Associated mimetype. Setting the mime will update the
+                          pygments lexer.
+        :param encoding: text encoding
+        """
+        super(CodeEditor, self).setPlainText(txt)
+        self.new_text_set.emit()
+
+    def focusOutEvent(self, event):
+        """Extend Qt method"""
+        self.sig_focus_changed.emit()
+        self._restore_editor_cursor_and_selections()
+        super(CodeEditor, self).focusOutEvent(event)
+
+    def focusInEvent(self, event):
+        formatting_enabled = getattr(self, 'formatting_enabled', False)
+        self.sig_refresh_formatting.emit(formatting_enabled)
+        super(CodeEditor, self).focusInEvent(event)
+
+    def leaveEvent(self, event):
+        """Extend Qt method"""
+        self.sig_leave_out.emit()
+        self._restore_editor_cursor_and_selections()
+        TextEditBaseWidget.leaveEvent(self, event)
+
+    def mousePressEvent(self, event):
+        """Override Qt method."""
+        self.hide_tooltip()
+
+        ctrl = event.modifiers() & Qt.ControlModifier
+        alt = event.modifiers() & Qt.AltModifier
+        pos = event.pos()
+        self._mouse_left_button_pressed = event.button() == Qt.LeftButton
+
+        if event.button() == Qt.LeftButton and ctrl:
+            TextEditBaseWidget.mousePressEvent(self, event)
+            cursor = self.cursorForPosition(pos)
+            uri = self._last_hover_pattern_text
+            if uri:
+                self.go_to_uri_from_cursor(uri)
+            else:
+                self.go_to_definition_from_cursor(cursor)
+        elif event.button() == Qt.LeftButton and alt:
+            self.sig_alt_left_mouse_pressed.emit(event)
+        else:
+            TextEditBaseWidget.mousePressEvent(self, event)
+
+    def mouseReleaseEvent(self, event):
+        """Override Qt method."""
+        if event.button() == Qt.LeftButton:
+            self._mouse_left_button_pressed = False
+
+        self.request_cursor_event()
+        TextEditBaseWidget.mouseReleaseEvent(self, event)
+
+    def contextMenuEvent(self, event):
+        """Reimplement Qt method"""
+        nonempty_selection = self.has_selected_text()
+        self.copy_action.setEnabled(nonempty_selection)
+        self.cut_action.setEnabled(nonempty_selection)
+        self.clear_all_output_action.setVisible(self.is_json() and
+                                                nbformat is not None)
+        self.ipynb_convert_action.setVisible(self.is_json() and
+                                             nbformat is not None)
+        self.gotodef_action.setVisible(self.go_to_definition_enabled)
+
+        formatter = self.get_conf(
+            ('provider_configuration', 'lsp', 'values', 'formatting'),
+            default='',
+            section='completions'
+        )
+        self.format_action.setText(_(
+            'Format file or selection with {0}').format(
+                formatter.capitalize()))
+
+        # Check if a docstring is writable
+        writer = self.writer_docstring
+        writer.line_number_cursor = self.get_line_number_at(event.pos())
+        result = writer.get_function_definition_from_first_line()
+
+        if result:
+            self.docstring_action.setEnabled(True)
+        else:
+            self.docstring_action.setEnabled(False)
+
+        # Code duplication go_to_definition_from_cursor and mouse_move_event
+        cursor = self.textCursor()
+        text = to_text_string(cursor.selectedText())
+        if len(text) == 0:
+            cursor.select(QTextCursor.WordUnderCursor)
+            text = to_text_string(cursor.selectedText())
+
+        self.undo_action.setEnabled(self.document().isUndoAvailable())
+        self.redo_action.setEnabled(self.document().isRedoAvailable())
+        menu = self.menu
+        if self.isReadOnly():
+            menu = self.readonly_menu
+        menu.popup(event.globalPos())
+        event.accept()
+
+    def _restore_editor_cursor_and_selections(self):
+        """Restore the cursor and extra selections of this code editor."""
+        if self.__cursor_changed:
+            self.__cursor_changed = False
+            QApplication.restoreOverrideCursor()
+            self.clear_extra_selections('ctrl_click')
+            self._last_hover_pattern_key = None
+            self._last_hover_pattern_text = None
+
+    # ---- Drag and drop
+    # -------------------------------------------------------------------------
+    def dragEnterEvent(self, event):
+        """
+        Reimplemented Qt method.
+
+        Inform Qt about the types of data that the widget accepts.
+        """
+        logger.debug("dragEnterEvent was received")
+        all_urls = mimedata2url(event.mimeData())
+        if all_urls:
+            # Let the parent widget handle this
+            logger.debug("Let the parent widget handle this dragEnterEvent")
+            event.ignore()
+        else:
+            logger.debug("Call TextEditBaseWidget dragEnterEvent method")
+            TextEditBaseWidget.dragEnterEvent(self, event)
+
+    def dropEvent(self, event):
+        """
+        Reimplemented Qt method.
+
+        Unpack dropped data and handle it.
+        """
+        logger.debug("dropEvent was received")
+        if mimedata2url(event.mimeData()):
+            logger.debug("Let the parent widget handle this")
+            event.ignore()
+        else:
+            logger.debug("Call TextEditBaseWidget dropEvent method")
+            TextEditBaseWidget.dropEvent(self, event)
+
+    # ---- Paint event
+    # -------------------------------------------------------------------------
+    def paintEvent(self, event):
+        """Overrides paint event to update the list of visible blocks"""
+        self.update_visible_blocks(event)
+        TextEditBaseWidget.paintEvent(self, event)
+        self.painted.emit(event)
+
+    def update_visible_blocks(self, event):
+        """Update the list of visible blocks/lines position"""
+        self.__visible_blocks[:] = []
+        block = self.firstVisibleBlock()
+        blockNumber = block.blockNumber()
+        top = int(self.blockBoundingGeometry(block).translated(
+            self.contentOffset()).top())
+        bottom = top + int(self.blockBoundingRect(block).height())
+        ebottom_bottom = self.height()
+
+        while block.isValid():
+            visible = bottom <= ebottom_bottom
+            if not visible:
+                break
+            if block.isVisible():
+                self.__visible_blocks.append((top, blockNumber+1, block))
+            block = block.next()
+            top = bottom
+            bottom = top + int(self.blockBoundingRect(block).height())
+            blockNumber = block.blockNumber()
+
+    def _draw_editor_cell_divider(self):
+        """Draw a line on top of a define cell"""
+        if self.supported_cell_language:
+            cell_line_color = self.comment_color
+            painter = QPainter(self.viewport())
+            pen = painter.pen()
+            pen.setStyle(Qt.SolidLine)
+            pen.setBrush(cell_line_color)
+            painter.setPen(pen)
+
+            for top, line_number, block in self.visible_blocks:
+                if is_cell_header(block):
+                    painter.drawLine(0, top, self.width(), top)
+
+    @property
+    def visible_blocks(self):
+        """
+        Returns the list of visible blocks.
+
+        Each element in the list is a tuple made up of the line top position,
+        the line number (already 1 based), and the QTextBlock itself.
+
+        :return: A list of tuple(top position, line number, block)
+        :rtype: List of tuple(int, int, QtGui.QTextBlock)
+        """
+        return self.__visible_blocks
+
+    def is_editor(self):
+        return True
+
+    def popup_docstring(self, prev_text, prev_pos):
+        """Show the menu for generating docstring."""
+        line_text = self.textCursor().block().text()
+        if line_text != prev_text:
+            return
+
+        if prev_pos != self.textCursor().position():
+            return
+
+        writer = self.writer_docstring
+        if writer.get_function_definition_from_below_last_line():
+            point = self.cursorRect().bottomRight()
+            point = self.calculate_real_position(point)
+            point = self.mapToGlobal(point)
+
+            self.menu_docstring = QMenuOnlyForEnter(self)
+            self.docstring_action = create_action(
+                self, _("Generate docstring"), icon=ima.icon('TextFileIcon'),
+                triggered=writer.write_docstring)
+            self.menu_docstring.addAction(self.docstring_action)
+            self.menu_docstring.setActiveAction(self.docstring_action)
+            self.menu_docstring.popup(point)
+
+    def delayed_popup_docstring(self):
+        """Show context menu for docstring.
+
+        This method is called after typing '''. After typing ''', this function
+        waits 300ms. If there was no input for 300ms, show the context menu.
+        """
+        line_text = self.textCursor().block().text()
+        pos = self.textCursor().position()
+
+        timer = QTimer()
+        timer.singleShot(300, lambda: self.popup_docstring(line_text, pos))
+
+    def set_current_project_path(self, root_path=None):
+        """
+        Set the current active project root path.
+
+        Parameters
+        ----------
+        root_path: str or None, optional
+            Path to current project root path. Default is None.
+        """
+        self.current_project_path = root_path
+
+    def count_leading_empty_lines(self, cell):
+        """Count the number of leading empty cells."""
+        lines = cell.splitlines(keepends=True)
+        if not lines:
+            return 0
+        for i, line in enumerate(lines):
+            if line and not line.isspace():
+                return i
+        return len(lines)
+
+    def ipython_to_python(self, code):
+        """Transform IPython code to python code."""
+        tm = TransformerManager()
+        number_empty_lines = self.count_leading_empty_lines(code)
+        try:
+            code = tm.transform_cell(code)
+        except SyntaxError:
+            return code
+        return '\n' * number_empty_lines + code
+
+    def is_letter_or_number(self, char):
+        """
+        Returns whether the specified unicode character is a letter or a
+        number.
+        """
+        cat = category(char)
+        return cat.startswith('L') or cat.startswith('N')
+
+
+# =============================================================================
+# Editor + Class browser test
+# =============================================================================
+class TestWidget(QSplitter):
+    def __init__(self, parent):
+        QSplitter.__init__(self, parent)
+        self.editor = CodeEditor(self)
+        self.editor.setup_editor(linenumbers=True, markers=True, tab_mode=False,
+                                 font=QFont("Courier New", 10),
+                                 show_blanks=True, color_scheme='Zenburn')
+        self.addWidget(self.editor)
+        self.setWindowIcon(ima.icon('spyder'))
+
+    def load(self, filename):
+        self.editor.set_text_from_file(filename)
+        self.setWindowTitle("%s - %s (%s)" % (_("Editor"),
+                                              osp.basename(filename),
+                                              osp.dirname(filename)))
+        self.editor.hide_tooltip()
+
+
+def test(fname):
+    from spyder.utils.qthelpers import qapplication
+    app = qapplication(test_time=5)
+    win = TestWidget(None)
+    win.show()
+    win.load(fname)
+    win.resize(900, 700)
+    sys.exit(app.exec_())
+
+
+if __name__ == '__main__':
+    if len(sys.argv) > 1:
+        fname = sys.argv[1]
+    else:
+        fname = __file__
+    test(fname)
diff --git a/tests/corpus/refactor-benchmark.indentation-size-discovery/expected.autosave.py b/tests/corpus/refactor-benchmark.indentation-size-discovery/expected.autosave.py
new file mode 100644
index 0000000..bea77c1
--- /dev/null
+++ b/tests/corpus/refactor-benchmark.indentation-size-discovery/expected.autosave.py
@@ -0,0 +1,445 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Autosave components for the Editor plugin and the EditorStack widget
+
+The autosave system regularly checks the contents of all opened files and saves
+a copy in the autosave directory if the contents are different from the
+autosave file (if it exists) or original file (if there is no autosave file).
+
+The mapping between original files and autosave files is stored in the
+variable `name_mapping` and saved in the file `pidNNN.txt` in the autosave
+directory, where `NNN` stands for the pid. This filename is chosen so that
+multiple instances of Spyder can run simultaneously.
+
+File contents are compared using their hash. The variable `file_hashes`
+contains the hash of all files currently open in the editor and all autosave
+files.
+
+On startup, the contents of the autosave directory is checked and if autosave
+files are found, the user is asked whether to recover them;
+see `spyder/plugins/editor/widgets/recover.py`.
+"""
+
+# Standard library imports
+import ast
+import logging
+import os
+import os.path as osp
+import re
+
+# Third party imports
+from qtpy.QtCore import QTimer
+
+# Local imports
+from spyder.config.base import _, get_conf_path, running_under_pytest
+from spyder.plugins.editor.widgets.autosaveerror import AutosaveErrorDialog
+from spyder.plugins.editor.widgets.recover import RecoveryDialog
+from spyder.utils.programs import is_spyder_process
+
+
+logger = logging.getLogger(__name__)
+
+
+def get_files_to_recover(self):
+    """
+    Get list of files to recover from pid files in autosave dir.
+
+    This returns a tuple `(files_to_recover, pid_files)`. In this tuple,
+    `files_to_recover` is a list of tuples containing the original file
+    names and the corresponding autosave file names, as recorded in the
+    pid files in the autosave directory. Any files in the autosave
+    directory which are not listed in a pid file, are also included, with
+    the original file name set to `None`. The second entry, `pid_files`,
+    is a list with the names of the pid files.
+    """
+    autosave_dir = get_conf_path('autosave')
+    if not os.access(autosave_dir, os.R_OK):
+        return [], []
+
+    files_to_recover = []
+    files_mentioned = []
+    pid_files = []
+    non_pid_files = []
+
+    # In Python 3, easier to use os.scandir()
+    for name in os.listdir(autosave_dir):
+        full_name = osp.join(autosave_dir, name)
+        match = re.match(r'pid([0-9]*)\.txt\Z', name)
+        if match:
+            pid_files.append(full_name)
+            logger.debug('Reading pid file: {}'.format(full_name))
+            with open(full_name) as pidfile:
+                txt = pidfile.read()
+                try:
+                    txt_as_dict = ast.literal_eval(txt)
+                except (SyntaxError, ValueError):
+                    # Pid file got corrupted, see spyder-ide/spyder#11375
+                    logger.error('Error parsing pid file {}'
+                                .format(full_name))
+                    logger.error('Contents: {}'.format(repr(txt)))
+                    txt_as_dict = {}
+                files_mentioned += [autosave for (orig, autosave)
+                                    in txt_as_dict.items()]
+            pid = int(match.group(1))
+            if is_spyder_process(pid):
+                logger.debug('Ignoring files in {}'.format(full_name))
+            else:
+                files_to_recover += list(txt_as_dict.items())
+        else:
+            non_pid_files.append(full_name)
+
+    # Add all files not mentioned in any pid file. This can only happen if
+    # the pid file somehow got corrupted.
+    for filename in set(non_pid_files) - set(files_mentioned):
+        files_to_recover.append((None, filename))
+        logger.debug('Added unmentioned file: {}'.format(filename))
+
+    return files_to_recover, pid_files
+class AutosaveForPlugin(object):
+    """
+    Component of editor plugin implementing autosave functionality.
+
+    Attributes:
+        name_mapping (dict): map between names of opened and autosave files.
+        file_hashes (dict): map between file names and hash of their contents.
+            This is used for both files opened in the editor and their
+            corresponding autosave files.
+    """
+
+    # Interval (in ms) between two autosaves
+    DEFAULT_AUTOSAVE_INTERVAL = 60 * 1000
+
+    def __init__(self, editor):
+        """
+        Constructor.
+
+        Autosave is disabled after construction and needs to be enabled
+        explicitly if required.
+
+        Args:
+            editor (Editor): editor plugin.
+        """
+        self.editor = editor
+        self.name_mapping = {}
+        self.file_hashes = {}
+        self.timer = QTimer(self.editor)
+        self.timer.setSingleShot(True)
+        self.timer.timeout.connect(self.do_autosave)
+        self._enabled = False  # Can't use setter here
+        self._interval = self.DEFAULT_AUTOSAVE_INTERVAL
+
+    @property
+    def enabled(self):
+        """
+        Get or set whether autosave component is enabled.
+
+        The setter will start or stop the autosave component if appropriate.
+        """
+        return self._enabled
+
+    @enabled.setter
+    def enabled(self, new_enabled):
+        if new_enabled == self.enabled:
+            return
+        self.stop_autosave_timer()
+        self._enabled = new_enabled
+        self.start_autosave_timer()
+
+    @property
+    def interval(self):
+        """
+        Interval between two autosaves, in milliseconds.
+
+        The setter will perform an autosave if the interval is changed and
+        autosave is enabled.
+        """
+        return self._interval
+
+    @interval.setter
+    def interval(self, new_interval):
+        if new_interval == self.interval:
+            return
+        self.stop_autosave_timer()
+        self._interval = new_interval
+        if self.enabled:
+            self.do_autosave()
+
+    def start_autosave_timer(self):
+        """
+        Start a timer which calls do_autosave() after `self.interval`.
+
+        The autosave timer is only started if autosave is enabled.
+        """
+        if self.enabled:
+            self.timer.start(self.interval)
+
+    def stop_autosave_timer(self):
+        """Stop the autosave timer."""
+        self.timer.stop()
+
+    def do_autosave(self):
+        """Instruct current editorstack to autosave files where necessary."""
+        logger.debug('Autosave triggered')
+        stack = self.editor.get_current_editorstack()
+        stack.autosave.autosave_all()
+        self.start_autosave_timer()
+
+
+    def try_recover_from_autosave(self):
+        """
+        Offer to recover files from autosave.
+
+        Read pid files to get a list of files that can possibly be recovered,
+        then ask the user what to do with these files, and finally remove
+        the pid files.
+        """
+        files_to_recover, pidfiles = self.get_files_to_recover()
+        parent = self.editor if running_under_pytest() else self.editor.main
+        dialog = RecoveryDialog(files_to_recover, parent=parent)
+        dialog.exec_if_nonempty()
+        self.recover_files_to_open = dialog.files_to_open[:]
+        for pidfile in pidfiles:
+            try:
+                os.remove(pidfile)
+            except (IOError, OSError):
+                pass
+
+    def register_autosave_for_stack(self, autosave_for_stack):
+        """
+        Register an AutosaveForStack object.
+
+        This replaces the `name_mapping` and `file_hashes` attributes
+        in `autosave_for_stack` with references to the corresponding
+        attributes of `self`, so that all AutosaveForStack objects
+        share the same data.
+        """
+        autosave_for_stack.name_mapping = self.name_mapping
+        autosave_for_stack.file_hashes = self.file_hashes
+
+
+class AutosaveForStack(object):
+    """
+    Component of EditorStack implementing autosave functionality.
+
+    In Spyder, the `name_mapping` and `file_hashes` are set to references to
+    the corresponding variables in `AutosaveForPlugin`.
+
+    Attributes:
+        stack (EditorStack): editor stack this component belongs to.
+        name_mapping (dict): map between names of opened and autosave files.
+        file_hashes (dict): map between file names and hash of their contents.
+            This is used for both files opened in the editor and their
+            corresponding autosave files.
+    """
+
+    def __init__(self, editorstack):
+        """
+        Constructor.
+
+        Args:
+            editorstack (EditorStack): editor stack this component belongs to.
+        """
+        self.stack = editorstack
+        self.name_mapping = {}
+        self.file_hashes = {}
+
+    def create_unique_autosave_filename(self, filename, autosave_dir):
+        """
+        Create unique autosave file name for specified file name.
+
+        The created autosave file name does not yet exist either in
+        `self.name_mapping` or on disk.
+
+        Args:
+            filename (str): original file name
+            autosave_dir (str): directory in which autosave files are stored
+        """
+        basename = osp.basename(filename)
+        autosave_filename = osp.join(autosave_dir, basename)
+        if (autosave_filename in self.name_mapping.values()
+                or osp.exists(autosave_filename)):
+            counter = 0
+            root, ext = osp.splitext(basename)
+            while (autosave_filename in self.name_mapping.values()
+                   or osp.exists(autosave_filename)):
+                counter += 1
+                autosave_basename = '{}-{}{}'.format(root, counter, ext)
+                autosave_filename = osp.join(autosave_dir, autosave_basename)
+        return autosave_filename
+
+    def save_autosave_mapping(self):
+        """
+        Writes current autosave mapping to a pidNNN.txt file.
+
+        This function should be called after updating `self.autosave_mapping`.
+        The NNN in the file name is the pid of the Spyder process. If the
+        current autosave mapping is empty, then delete the file if it exists.
+        """
+        autosave_dir = get_conf_path('autosave')
+        my_pid = os.getpid()
+        pidfile_name = osp.join(autosave_dir, 'pid{}.txt'.format(my_pid))
+        if self.name_mapping:
+            with open(pidfile_name, 'w') as pidfile:
+                pidfile.write(ascii(self.name_mapping))
+        else:
+            try:
+                os.remove(pidfile_name)
+            except (IOError, OSError):
+                pass
+
+    def remove_autosave_file(self, filename):
+        """
+        Remove autosave file for specified file.
+
+        This function also updates `self.name_mapping` and `self.file_hashes`.
+        If there is no autosave file, then the function returns without doing
+        anything.
+        """
+        if filename not in self.name_mapping:
+            return
+        autosave_filename = self.name_mapping[filename]
+        try:
+            os.remove(autosave_filename)
+        except EnvironmentError as error:
+            action = (_('Error while removing autosave file {}')
+                      .format(autosave_filename))
+            msgbox = AutosaveErrorDialog(action, error)
+            msgbox.exec_if_enabled()
+        del self.name_mapping[filename]
+
+        # This is necessary to catch an error when a file is changed externally
+        # but it's left unsaved in Spyder.
+        # Fixes spyder-ide/spyder#19283
+        try:
+            del self.file_hashes[autosave_filename]
+        except KeyError:
+            pass
+
+        self.save_autosave_mapping()
+        logger.debug('Removing autosave file %s', autosave_filename)
+
+    def get_autosave_filename(self, filename):
+        """
+        Get name of autosave file for specified file name.
+
+        This function uses the dict in `self.name_mapping`. If `filename` is
+        in the mapping, then return the corresponding autosave file name.
+        Otherwise, construct a unique file name and update the mapping.
+
+        Args:
+            filename (str): original file name
+        """
+        try:
+            autosave_filename = self.name_mapping[filename]
+        except KeyError:
+            autosave_dir = get_conf_path('autosave')
+            if not osp.isdir(autosave_dir):
+                try:
+                    os.mkdir(autosave_dir)
+                except EnvironmentError as error:
+                    action = _('Error while creating autosave directory')
+                    msgbox = AutosaveErrorDialog(action, error)
+                    msgbox.exec_if_enabled()
+            autosave_filename = self.create_unique_autosave_filename(
+                filename, autosave_dir)
+            self.name_mapping[filename] = autosave_filename
+            self.save_autosave_mapping()
+            logger.debug('New autosave file name')
+        return autosave_filename
+
+    def maybe_autosave(self, index):
+        """
+        Autosave a file if necessary.
+
+        If the file is newly created (and thus not named by the user), do
+        nothing.  If the current contents are the same as the autosave file
+        (if it exists) or the original file (if no autosave filee exists),
+        then do nothing. If the current contents are the same as the file on
+        disc, but the autosave file is different, then remove the autosave
+        file. In all other cases, autosave the file.
+
+        Args:
+            index (int): index into self.stack.data
+        """
+        finfo = self.stack.data[index]
+        if finfo.newly_created:
+            return
+
+        orig_filename = finfo.filename
+        try:
+            orig_hash = self.file_hashes[orig_filename]
+        except KeyError:
+            # This should not happen, but it does: spyder-ide/spyder#11468
+            # In this case, use an impossible value for the hash, so that
+            # contents of buffer are considered different from contents of
+            # original file.
+            logger.debug('KeyError when retrieving hash of %s', orig_filename)
+            orig_hash = None
+
+        new_hash = self.stack.compute_hash(finfo)
+        if orig_filename in self.name_mapping:
+            autosave_filename = self.name_mapping[orig_filename]
+            autosave_hash = self.file_hashes[autosave_filename]
+            if new_hash != autosave_hash:
+                if new_hash == orig_hash:
+                    self.remove_autosave_file(orig_filename)
+                else:
+                    self.autosave(finfo)
+        else:
+            if new_hash != orig_hash:
+                self.autosave(finfo)
+
+    def autosave(self, finfo):
+        """
+        Autosave a file.
+
+        Save a copy in a file with name `self.get_autosave_filename()` and
+        update the cached hash of the autosave file. An error dialog notifies
+        the user of any errors raised when saving.
+
+        Args:
+            fileinfo (FileInfo): file that is to be autosaved.
+        """
+        autosave_filename = self.get_autosave_filename(finfo.filename)
+        logger.debug('Autosaving %s to %s', finfo.filename, autosave_filename)
+        try:
+            self.stack._write_to_file(finfo, autosave_filename)
+            autosave_hash = self.stack.compute_hash(finfo)
+            self.file_hashes[autosave_filename] = autosave_hash
+        except EnvironmentError as error:
+            action = (_('Error while autosaving {} to {}')
+                      .format(finfo.filename, autosave_filename))
+            msgbox = AutosaveErrorDialog(action, error)
+            msgbox.exec_if_enabled()
+
+    def autosave_all(self):
+        """Autosave all opened files where necessary."""
+        for index in range(self.stack.get_stack_count()):
+            self.maybe_autosave(index)
+
+    def file_renamed(self, old_name, new_name):
+        """
+        Update autosave files after a file is renamed.
+
+        Args:
+            old_name (str): name of file before it is renamed
+            new_name (str): name of file after it is renamed
+        """
+        try:
+            old_hash = self.file_hashes[old_name]
+        except KeyError:
+            # This should not happen, but it does: spyder-ide/spyder#12396
+            logger.debug('KeyError when handling rename %s -> %s',
+                         old_name, new_name)
+            old_hash = None
+        self.remove_autosave_file(old_name)
+        if old_hash is not None:
+            del self.file_hashes[old_name]
+            self.file_hashes[new_name] = old_hash
+        index = self.stack.has_filename(new_name)
+        self.maybe_autosave(index)
diff --git a/tests/corpus/refactor-benchmark.indentation-size-discovery/expected.base.py b/tests/corpus/refactor-benchmark.indentation-size-discovery/expected.base.py
new file mode 100644
index 0000000..56820ee
--- /dev/null
+++ b/tests/corpus/refactor-benchmark.indentation-size-discovery/expected.base.py
@@ -0,0 +1,373 @@
+import asyncio
+import logging
+import types
+
+from asgiref.sync import async_to_sync, sync_to_async
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured, MiddlewareNotUsed
+from django.core.signals import request_finished
+from django.db import connections, transaction
+from django.urls import get_resolver, set_urlconf
+from django.utils.log import log_response
+from django.utils.module_loading import import_string
+
+from .exception import convert_exception_to_response
+
+logger = logging.getLogger("django.request")
+
+
+def adapt_method_mode(
+    self,
+    is_async,
+    method,
+    method_is_async=None,
+    debug=False,
+    name=None,
+):
+    """
+    Adapt a method to be in the correct "mode":
+    - If is_async is False:
+    - Synchronous methods are left alone
+    - Asynchronous methods are wrapped with async_to_sync
+    - If is_async is True:
+    - Synchronous methods are wrapped with sync_to_async()
+    - Asynchronous methods are left alone
+    """
+    if method_is_async is None:
+        method_is_async = asyncio.iscoroutinefunction(method)
+    if debug and not name:
+        name = name or "method %s()" % method.__qualname__
+    if is_async:
+        if not method_is_async:
+            if debug:
+                logger.debug("Synchronous handler adapted for %s.", name)
+            return sync_to_async(method, thread_sensitive=True)
+    elif method_is_async:
+        if debug:
+            logger.debug("Asynchronous handler adapted for %s.", name)
+        return async_to_sync(method)
+    return method
+class BaseHandler:
+    _view_middleware = None
+    _template_response_middleware = None
+    _exception_middleware = None
+    _middleware_chain = None
+
+    def load_middleware(self, is_async=False):
+        """
+        Populate middleware lists from settings.MIDDLEWARE.
+
+        Must be called after the environment is fixed (see __call__ in subclasses).
+        """
+        self._view_middleware = []
+        self._template_response_middleware = []
+        self._exception_middleware = []
+
+        get_response = self._get_response_async if is_async else self._get_response
+        handler = convert_exception_to_response(get_response)
+        handler_is_async = is_async
+        for middleware_path in reversed(settings.MIDDLEWARE):
+            middleware = import_string(middleware_path)
+            middleware_can_sync = getattr(middleware, "sync_capable", True)
+            middleware_can_async = getattr(middleware, "async_capable", False)
+            if not middleware_can_sync and not middleware_can_async:
+                raise RuntimeError(
+                    "Middleware %s must have at least one of "
+                    "sync_capable/async_capable set to True." % middleware_path
+                )
+            elif not handler_is_async and middleware_can_sync:
+                middleware_is_async = False
+            else:
+                middleware_is_async = middleware_can_async
+            try:
+                # Adapt handler, if needed.
+                adapted_handler = self.adapt_method_mode(
+                    middleware_is_async,
+                    handler,
+                    handler_is_async,
+                    debug=settings.DEBUG,
+                    name="middleware %s" % middleware_path,
+                )
+                mw_instance = middleware(adapted_handler)
+            except MiddlewareNotUsed as exc:
+                if settings.DEBUG:
+                    if str(exc):
+                        logger.debug("MiddlewareNotUsed(%r): %s", middleware_path, exc)
+                    else:
+                        logger.debug("MiddlewareNotUsed: %r", middleware_path)
+                continue
+            else:
+                handler = adapted_handler
+
+            if mw_instance is None:
+                raise ImproperlyConfigured(
+                    "Middleware factory %s returned None." % middleware_path
+                )
+
+            if hasattr(mw_instance, "process_view"):
+                self._view_middleware.insert(
+                    0,
+                    self.adapt_method_mode(is_async, mw_instance.process_view),
+                )
+            if hasattr(mw_instance, "process_template_response"):
+                self._template_response_middleware.append(
+                    self.adapt_method_mode(
+                        is_async, mw_instance.process_template_response
+                    ),
+                )
+            if hasattr(mw_instance, "process_exception"):
+                # The exception-handling stack is still always synchronous for
+                # now, so adapt that way.
+                self._exception_middleware.append(
+                    self.adapt_method_mode(False, mw_instance.process_exception),
+                )
+
+            handler = convert_exception_to_response(mw_instance)
+            handler_is_async = middleware_is_async
+
+        # Adapt the top of the stack, if needed.
+        handler = self.adapt_method_mode(is_async, handler, handler_is_async)
+        # We only assign to this when initialization is complete as it is used
+        # as a flag for initialization being complete.
+        self._middleware_chain = handler
+
+
+    def get_response(self, request):
+        """Return an HttpResponse object for the given HttpRequest."""
+        # Setup default url resolver for this thread
+        set_urlconf(settings.ROOT_URLCONF)
+        response = self._middleware_chain(request)
+        response._resource_closers.append(request.close)
+        if response.status_code >= 400:
+            log_response(
+                "%s: %s",
+                response.reason_phrase,
+                request.path,
+                response=response,
+                request=request,
+            )
+        return response
+
+    async def get_response_async(self, request):
+        """
+        Asynchronous version of get_response.
+
+        Funneling everything, including WSGI, into a single async
+        get_response() is too slow. Avoid the context switch by using
+        a separate async response path.
+        """
+        # Setup default url resolver for this thread.
+        set_urlconf(settings.ROOT_URLCONF)
+        response = await self._middleware_chain(request)
+        response._resource_closers.append(request.close)
+        if response.status_code >= 400:
+            await sync_to_async(log_response, thread_sensitive=False)(
+                "%s: %s",
+                response.reason_phrase,
+                request.path,
+                response=response,
+                request=request,
+            )
+        return response
+
+    def _get_response(self, request):
+        """
+        Resolve and call the view, then apply view, exception, and
+        template_response middleware. This method is everything that happens
+        inside the request/response middleware.
+        """
+        response = None
+        callback, callback_args, callback_kwargs = self.resolve_request(request)
+
+        # Apply view middleware
+        for middleware_method in self._view_middleware:
+            response = middleware_method(
+                request, callback, callback_args, callback_kwargs
+            )
+            if response:
+                break
+
+        if response is None:
+            wrapped_callback = self.make_view_atomic(callback)
+            # If it is an asynchronous view, run it in a subthread.
+            if asyncio.iscoroutinefunction(wrapped_callback):
+                wrapped_callback = async_to_sync(wrapped_callback)
+            try:
+                response = wrapped_callback(request, *callback_args, **callback_kwargs)
+            except Exception as e:
+                response = self.process_exception_by_middleware(e, request)
+                if response is None:
+                    raise
+
+        # Complain if the view returned None (a common error).
+        self.check_response(response, callback)
+
+        # If the response supports deferred rendering, apply template
+        # response middleware and then render the response
+        if hasattr(response, "render") and callable(response.render):
+            for middleware_method in self._template_response_middleware:
+                response = middleware_method(request, response)
+                # Complain if the template response middleware returned None
+                # (a common error).
+                self.check_response(
+                    response,
+                    middleware_method,
+                    name="%s.process_template_response"
+                    % (middleware_method.__self__.__class__.__name__,),
+                )
+            try:
+                response = response.render()
+            except Exception as e:
+                response = self.process_exception_by_middleware(e, request)
+                if response is None:
+                    raise
+
+        return response
+
+    async def _get_response_async(self, request):
+        """
+        Resolve and call the view, then apply view, exception, and
+        template_response middleware. This method is everything that happens
+        inside the request/response middleware.
+        """
+        response = None
+        callback, callback_args, callback_kwargs = self.resolve_request(request)
+
+        # Apply view middleware.
+        for middleware_method in self._view_middleware:
+            response = await middleware_method(
+                request, callback, callback_args, callback_kwargs
+            )
+            if response:
+                break
+
+        if response is None:
+            wrapped_callback = self.make_view_atomic(callback)
+            # If it is a synchronous view, run it in a subthread
+            if not asyncio.iscoroutinefunction(wrapped_callback):
+                wrapped_callback = sync_to_async(
+                    wrapped_callback, thread_sensitive=True
+                )
+            try:
+                response = await wrapped_callback(
+                    request, *callback_args, **callback_kwargs
+                )
+            except Exception as e:
+                response = await sync_to_async(
+                    self.process_exception_by_middleware,
+                    thread_sensitive=True,
+                )(e, request)
+                if response is None:
+                    raise
+
+        # Complain if the view returned None or an uncalled coroutine.
+        self.check_response(response, callback)
+
+        # If the response supports deferred rendering, apply template
+        # response middleware and then render the response
+        if hasattr(response, "render") and callable(response.render):
+            for middleware_method in self._template_response_middleware:
+                response = await middleware_method(request, response)
+                # Complain if the template response middleware returned None or
+                # an uncalled coroutine.
+                self.check_response(
+                    response,
+                    middleware_method,
+                    name="%s.process_template_response"
+                    % (middleware_method.__self__.__class__.__name__,),
+                )
+            try:
+                if asyncio.iscoroutinefunction(response.render):
+                    response = await response.render()
+                else:
+                    response = await sync_to_async(
+                        response.render, thread_sensitive=True
+                    )()
+            except Exception as e:
+                response = await sync_to_async(
+                    self.process_exception_by_middleware,
+                    thread_sensitive=True,
+                )(e, request)
+                if response is None:
+                    raise
+
+        # Make sure the response is not a coroutine
+        if asyncio.iscoroutine(response):
+            raise RuntimeError("Response is still a coroutine.")
+        return response
+
+    def resolve_request(self, request):
+        """
+        Retrieve/set the urlconf for the request. Return the view resolved,
+        with its args and kwargs.
+        """
+        # Work out the resolver.
+        if hasattr(request, "urlconf"):
+            urlconf = request.urlconf
+            set_urlconf(urlconf)
+            resolver = get_resolver(urlconf)
+        else:
+            resolver = get_resolver()
+        # Resolve the view, and assign the match object back to the request.
+        resolver_match = resolver.resolve(request.path_info)
+        request.resolver_match = resolver_match
+        return resolver_match
+
+    def check_response(self, response, callback, name=None):
+        """
+        Raise an error if the view returned None or an uncalled coroutine.
+        """
+        if not (response is None or asyncio.iscoroutine(response)):
+            return
+        if not name:
+            if isinstance(callback, types.FunctionType):  # FBV
+                name = "The view %s.%s" % (callback.__module__, callback.__name__)
+            else:  # CBV
+                name = "The view %s.%s.__call__" % (
+                    callback.__module__,
+                    callback.__class__.__name__,
+                )
+        if response is None:
+            raise ValueError(
+                "%s didn't return an HttpResponse object. It returned None "
+                "instead." % name
+            )
+        elif asyncio.iscoroutine(response):
+            raise ValueError(
+                "%s didn't return an HttpResponse object. It returned an "
+                "unawaited coroutine instead. You may need to add an 'await' "
+                "into your view." % name
+            )
+
+    # Other utility methods.
+
+    def make_view_atomic(self, view):
+        non_atomic_requests = getattr(view, "_non_atomic_requests", set())
+        for alias, settings_dict in connections.settings.items():
+            if settings_dict["ATOMIC_REQUESTS"] and alias not in non_atomic_requests:
+                if asyncio.iscoroutinefunction(view):
+                    raise RuntimeError(
+                        "You cannot use ATOMIC_REQUESTS with async views."
+                    )
+                view = transaction.atomic(using=alias)(view)
+        return view
+
+    def process_exception_by_middleware(self, exception, request):
+        """
+        Pass the exception to the exception middleware. If no middleware
+        return a response for this exception, return None.
+        """
+        for middleware_method in self._exception_middleware:
+            response = middleware_method(request, exception)
+            if response:
+                return response
+        return None
+
+
+def reset_urlconf(sender, **kwargs):
+    """Reset the URLconf after each request is finished."""
+    set_urlconf(None)
+
+
+request_finished.connect(reset_urlconf)
diff --git a/tests/corpus/refactor-benchmark.indentation-size-discovery/expected.codeeditor.py b/tests/corpus/refactor-benchmark.indentation-size-discovery/expected.codeeditor.py
new file mode 100644
index 0000000..60ef066
--- /dev/null
+++ b/tests/corpus/refactor-benchmark.indentation-size-discovery/expected.codeeditor.py
@@ -0,0 +1,4431 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Editor widget based on QtGui.QPlainTextEdit
+"""
+
+# TODO: Try to separate this module from spyder to create a self
+#       consistent editor module (Qt source code and shell widgets library)
+
+# pylint: disable=C0103
+# pylint: disable=R0903
+# pylint: disable=R0911
+# pylint: disable=R0201
+
+# Standard library imports
+from unicodedata import category
+import logging
+import os
+import os.path as osp
+import re
+import sre_constants
+import sys
+import textwrap
+
+# Third party imports
+from IPython.core.inputtransformer2 import TransformerManager
+from packaging.version import parse
+from qtpy import QT_VERSION
+from qtpy.compat import to_qvariant
+from qtpy.QtCore import (
+    QEvent, QRegularExpression, Qt, QTimer, QUrl, Signal, Slot)
+from qtpy.QtGui import (QColor, QCursor, QFont, QKeySequence, QPaintEvent,
+                        QPainter, QMouseEvent, QTextCursor, QDesktopServices,
+                        QKeyEvent, QTextDocument, QTextFormat, QTextOption,
+                        QTextCharFormat, QTextLayout)
+from qtpy.QtWidgets import (QApplication, QMenu, QMessageBox, QSplitter,
+                            QScrollBar)
+from spyder_kernels.utils.dochelpers import getobj
+
+
+# Local imports
+from spyder.config.base import _, running_under_pytest
+from spyder.plugins.editor.api.decoration import TextDecoration
+from spyder.plugins.editor.api.panel import Panel
+from spyder.plugins.editor.extensions import (CloseBracketsExtension,
+                                              CloseQuotesExtension,
+                                              DocstringWriterExtension,
+                                              QMenuOnlyForEnter,
+                                              EditorExtensionsManager,
+                                              SnippetsExtension)
+from spyder.plugins.completion.api import DiagnosticSeverity
+from spyder.plugins.editor.panels import (
+    ClassFunctionDropdown, EdgeLine, FoldingPanel, IndentationGuide,
+    LineNumberArea, PanelsManager, ScrollFlagArea)
+from spyder.plugins.editor.utils.editor import (TextHelper, BlockUserData,
+                                                get_file_language)
+from spyder.plugins.editor.utils.kill_ring import QtKillRing
+from spyder.plugins.editor.utils.languages import ALL_LANGUAGES, CELL_LANGUAGES
+from spyder.plugins.editor.widgets.gotoline import GoToLineDialog
+from spyder.plugins.editor.widgets.base import TextEditBaseWidget
+from spyder.plugins.editor.widgets.codeeditor.lsp_mixin import LSPMixin
+from spyder.plugins.outlineexplorer.api import (OutlineExplorerData as OED,
+                                                is_cell_header)
+from spyder.py3compat import to_text_string, is_string
+from spyder.utils import encoding, sourcecode
+from spyder.utils.clipboard_helper import CLIPBOARD_HELPER
+from spyder.utils.icon_manager import ima
+from spyder.utils import syntaxhighlighters as sh
+from spyder.utils.palette import SpyderPalette, QStylePalette
+from spyder.utils.qthelpers import (add_actions, create_action, file_uri,
+                                    mimedata2url, start_file)
+from spyder.utils.vcs import get_git_remotes, remote_to_url
+from spyder.utils.qstringhelpers import qstring_length
+
+
+try:
+    import nbformat as nbformat
+    from nbconvert import PythonExporter as nbexporter
+except Exception:
+    nbformat = None  # analysis:ignore
+
+logger = logging.getLogger(__name__)
+
+
+def __get_brackets(self, line_text, closing_brackets=[]):
+    """
+    Return unmatched opening brackets and left-over closing brackets.
+
+    (str, []) -> ([(pos, bracket)], [bracket], comment_pos)
+
+    Iterate through line_text to find unmatched brackets.
+
+    Returns three objects as a tuple:
+    1) bracket_stack:
+        a list of tuples of pos and char of each unmatched opening bracket
+    2) closing brackets:
+        this line's unmatched closing brackets + arg closing_brackets.
+        If this line ad no closing brackets, arg closing_brackets might
+        be matched with previously unmatched opening brackets in this line.
+    3) Pos at which a # comment begins. -1 if it doesn't.'
+    """
+    # Remove inline comment and check brackets
+    bracket_stack = []  # list containing this lines unmatched opening
+    # same deal, for closing though. Ignore if bracket stack not empty,
+    # since they are mismatched in that case.
+    bracket_unmatched_closing = []
+    comment_pos = -1
+    deactivate = None
+    escaped = False
+    pos, c = None, None
+    for pos, c in enumerate(line_text):
+        # Handle '\' inside strings
+        if escaped:
+            escaped = False
+        # Handle strings
+        elif deactivate:
+            if c == deactivate:
+                deactivate = None
+            elif c == "\\":
+                escaped = True
+        elif c in ["'", '"']:
+            deactivate = c
+        # Handle comments
+        elif c == "#":
+            comment_pos = pos
+            break
+        # Handle brackets
+        elif c in ('(', '[', '{'):
+            bracket_stack.append((pos, c))
+        elif c in (')', ']', '}'):
+            if bracket_stack and bracket_stack[-1][1] == \
+                    {')': '(', ']': '[', '}': '{'}[c]:
+                bracket_stack.pop()
+            else:
+                bracket_unmatched_closing.append(c)
+    del pos, deactivate, escaped
+    # If no closing brackets are left over from this line,
+    # check the ones from previous iterations' prevlines
+    if not bracket_unmatched_closing:
+        for c in list(closing_brackets):
+            if bracket_stack and bracket_stack[-1][1] == \
+                    {')': '(', ']': '[', '}': '{'}[c]:
+                bracket_stack.pop()
+                closing_brackets.remove(c)
+            else:
+                break
+    del c
+    closing_brackets = bracket_unmatched_closing + closing_brackets
+    return (bracket_stack, closing_brackets, comment_pos)
+class CodeEditor(LSPMixin, TextEditBaseWidget):
+    """Source Code Editor Widget based exclusively on Qt"""
+
+    CONF_SECTION = 'editor'
+
+    LANGUAGES = {
+        'Python': (sh.PythonSH, '#'),
+        'IPython': (sh.IPythonSH, '#'),
+        'Cython': (sh.CythonSH, '#'),
+        'Fortran77': (sh.Fortran77SH, 'c'),
+        'Fortran': (sh.FortranSH, '!'),
+        'Idl': (sh.IdlSH, ';'),
+        'Diff': (sh.DiffSH, ''),
+        'GetText': (sh.GetTextSH, '#'),
+        'Nsis': (sh.NsisSH, '#'),
+        'Html': (sh.HtmlSH, ''),
+        'Yaml': (sh.YamlSH, '#'),
+        'Cpp': (sh.CppSH, '//'),
+        'OpenCL': (sh.OpenCLSH, '//'),
+        'Enaml': (sh.EnamlSH, '#'),
+        'Markdown': (sh.MarkdownSH, '#'),
+        # Every other language
+        'None': (sh.TextSH, ''),
+    }
+
+    TAB_ALWAYS_INDENTS = (
+        'py', 'pyw', 'python', 'ipy', 'c', 'cpp', 'cl', 'h', 'pyt', 'pyi'
+    )
+
+    # Timeout to update decorations (through a QTimer) when a position
+    # changed is detected in the vertical scrollbar or when releasing
+    # the up/down arrow keys.
+    UPDATE_DECORATIONS_TIMEOUT = 500  # milliseconds
+
+    # Custom signal to be emitted upon completion of the editor's paintEvent
+    painted = Signal(QPaintEvent)
+
+    # To have these attrs when early viewportEvent's are triggered
+    edge_line = None
+    indent_guides = None
+
+    sig_filename_changed = Signal(str)
+    sig_bookmarks_changed = Signal()
+    go_to_definition = Signal(str, int, int)
+    sig_show_object_info = Signal(bool)
+    sig_cursor_position_changed = Signal(int, int)
+    sig_new_file = Signal(str)
+    sig_refresh_formatting = Signal(bool)
+
+    #: Signal emitted when the editor loses focus
+    sig_focus_changed = Signal()
+
+    #: Signal emitted when a key is pressed
+    sig_key_pressed = Signal(QKeyEvent)
+
+    #: Signal emitted when a key is released
+    sig_key_released = Signal(QKeyEvent)
+
+    #: Signal emitted when the alt key is pressed and the left button of the
+    #  mouse is clicked
+    sig_alt_left_mouse_pressed = Signal(QMouseEvent)
+
+    #: Signal emitted when the alt key is pressed and the cursor moves over
+    #  the editor
+    sig_alt_mouse_moved = Signal(QMouseEvent)
+
+    #: Signal emitted when the cursor leaves the editor
+    sig_leave_out = Signal()
+
+    #: Signal emitted when the flags need to be updated in the scrollflagarea
+    sig_flags_changed = Signal()
+
+    #: Signal emitted when the syntax color theme of the editor.
+    sig_theme_colors_changed = Signal(dict)
+
+    #: Signal emitted when a new text is set on the widget
+    new_text_set = Signal()
+
+    # Used for testing. When the mouse moves with Ctrl/Cmd pressed and
+    # a URI is found, this signal is emitted
+    sig_uri_found = Signal(str)
+
+    sig_file_uri_preprocessed = Signal(str)
+    """
+    This signal is emitted when the go to uri for a file has been
+    preprocessed.
+
+    Parameters
+    ----------
+    fpath: str
+        The preprocessed file path.
+    """
+
+    # Signal with the info about the current completion item documentation
+    # str: object name
+    # str: object signature/documentation
+    # bool: force showing the info
+    sig_show_completion_object_info = Signal(str, str, bool)
+
+    # Used to indicate if text was inserted into the editor
+    sig_text_was_inserted = Signal()
+
+    # Used to indicate that text will be inserted into the editor
+    sig_will_insert_text = Signal(str)
+
+    # Used to indicate that a text selection will be removed
+    sig_will_remove_selection = Signal(tuple, tuple)
+
+    # Used to indicate that text will be pasted
+    sig_will_paste_text = Signal(str)
+
+    # Used to indicate that an undo operation will take place
+    sig_undo = Signal()
+
+    # Used to indicate that an undo operation will take place
+    sig_redo = Signal()
+
+    # Used to signal font change
+    sig_font_changed = Signal()
+
+    # Used to request saving a file
+    sig_save_requested = Signal()
+
+    def __init__(self, parent=None):
+        super().__init__(parent=parent)
+
+        self.setFocusPolicy(Qt.StrongFocus)
+
+        # Projects
+        self.current_project_path = None
+
+        # Caret (text cursor)
+        self.setCursorWidth(self.get_conf('cursor/width', section='main'))
+
+        self.text_helper = TextHelper(self)
+
+        self._panels = PanelsManager(self)
+
+        # Mouse moving timer / Hover hints handling
+        # See: mouseMoveEvent
+        self.tooltip_widget.sig_help_requested.connect(
+            self.show_object_info)
+        self.tooltip_widget.sig_completion_help_requested.connect(
+            self.show_completion_object_info)
+        self._last_point = None
+        self._last_hover_word = None
+        self._last_hover_cursor = None
+        self._timer_mouse_moving = QTimer(self)
+        self._timer_mouse_moving.setInterval(350)
+        self._timer_mouse_moving.setSingleShot(True)
+        self._timer_mouse_moving.timeout.connect(self._handle_hover)
+
+        # Typing keys / handling for on the fly completions
+        self._last_key_pressed_text = ''
+        self._last_pressed_key = None
+
+        # Handle completions hints
+        self._completions_hint_idle = False
+        self._timer_completions_hint = QTimer(self)
+        self._timer_completions_hint.setSingleShot(True)
+        self._timer_completions_hint.timeout.connect(
+            self._set_completions_hint_idle)
+        self.completion_widget.sig_completion_hint.connect(
+            self.show_hint_for_completion)
+
+        # Goto uri
+        self._last_hover_pattern_key = None
+        self._last_hover_pattern_text = None
+
+        # 79-col edge line
+        self.edge_line = self.panels.register(EdgeLine(),
+                                              Panel.Position.FLOATING)
+
+        # indent guides
+        self.indent_guides = self.panels.register(IndentationGuide(),
+                                                  Panel.Position.FLOATING)
+        # Blanks enabled
+        self.blanks_enabled = False
+
+        # Underline errors and warnings
+        self.underline_errors_enabled = False
+
+        # Scrolling past the end of the document
+        self.scrollpastend_enabled = False
+
+        self.background = QColor('white')
+
+        # Folding
+        self.panels.register(FoldingPanel())
+
+        # Line number area management
+        self.linenumberarea = self.panels.register(LineNumberArea())
+
+        # Class and Method/Function Dropdowns
+        self.classfuncdropdown = self.panels.register(
+            ClassFunctionDropdown(),
+            Panel.Position.TOP,
+        )
+
+        # Colors to be defined in _apply_highlighter_color_scheme()
+        # Currentcell color and current line color are defined in base.py
+        self.occurrence_color = None
+        self.ctrl_click_color = None
+        self.sideareas_color = None
+        self.matched_p_color = None
+        self.unmatched_p_color = None
+        self.normal_color = None
+        self.comment_color = None
+
+        # --- Syntax highlight entrypoint ---
+        #
+        # - if set, self.highlighter is responsible for
+        #   - coloring raw text data inside editor on load
+        #   - coloring text data when editor is cloned
+        #   - updating document highlight on line edits
+        #   - providing color palette (scheme) for the editor
+        #   - providing data for Outliner
+        # - self.highlighter is not responsible for
+        #   - background highlight for current line
+        #   - background highlight for search / current line occurrences
+
+        self.highlighter_class = sh.TextSH
+        self.highlighter = None
+        ccs = 'Spyder'
+        if ccs not in sh.COLOR_SCHEME_NAMES:
+            ccs = sh.COLOR_SCHEME_NAMES[0]
+        self.color_scheme = ccs
+
+        self.highlight_current_line_enabled = False
+
+        # Vertical scrollbar
+        # This is required to avoid a "RuntimeError: no access to protected
+        # functions or signals for objects not created from Python" in
+        # Linux Ubuntu. See spyder-ide/spyder#5215.
+        self.setVerticalScrollBar(QScrollBar())
+
+        # Highlights and flag colors
+        self.warning_color = SpyderPalette.COLOR_WARN_2
+        self.error_color = SpyderPalette.COLOR_ERROR_1
+        self.todo_color = SpyderPalette.GROUP_9
+        self.breakpoint_color = SpyderPalette.ICON_3
+        self.occurrence_color = QColor(SpyderPalette.GROUP_2).lighter(160)
+        self.found_results_color = QColor(SpyderPalette.COLOR_OCCURRENCE_4)
+
+        # Scrollbar flag area
+        self.scrollflagarea = self.panels.register(ScrollFlagArea(),
+                                                   Panel.Position.RIGHT)
+        self.panels.refresh()
+
+        self.document_id = id(self)
+
+        # Indicate occurrences of the selected word
+        self.cursorPositionChanged.connect(self.__cursor_position_changed)
+        self.__find_first_pos = None
+        self.__find_args = {}
+
+        self.language = None
+        self.supported_language = False
+        self.supported_cell_language = False
+        self.comment_string = None
+        self._kill_ring = QtKillRing(self)
+
+        # Block user data
+        self.blockCountChanged.connect(self.update_bookmarks)
+
+        # Highlight using Pygments highlighter timer
+        # ---------------------------------------------------------------------
+        # For files that use the PygmentsSH we parse the full file inside
+        # the highlighter in order to generate the correct coloring.
+        self.timer_syntax_highlight = QTimer(self)
+        self.timer_syntax_highlight.setSingleShot(True)
+        self.timer_syntax_highlight.timeout.connect(
+            self.run_pygments_highlighter)
+
+        # Mark occurrences timer
+        self.occurrence_highlighting = None
+        self.occurrence_timer = QTimer(self)
+        self.occurrence_timer.setSingleShot(True)
+        self.occurrence_timer.setInterval(1500)
+        self.occurrence_timer.timeout.connect(self.mark_occurrences)
+        self.occurrences = []
+
+        # Update decorations
+        self.update_decorations_timer = QTimer(self)
+        self.update_decorations_timer.setSingleShot(True)
+        self.update_decorations_timer.setInterval(
+            self.UPDATE_DECORATIONS_TIMEOUT)
+        self.update_decorations_timer.timeout.connect(
+            self.update_decorations)
+        self.verticalScrollBar().valueChanged.connect(
+            lambda value: self.update_decorations_timer.start())
+
+        # QTextEdit + LSPMixin
+        self.textChanged.connect(self._schedule_document_did_change)
+
+        # Mark found results
+        self.textChanged.connect(self.__text_has_changed)
+        self.found_results = []
+
+        # Docstring
+        self.writer_docstring = DocstringWriterExtension(self)
+
+        # Context menu
+        self.gotodef_action = None
+        self.setup_context_menu()
+
+        # Tab key behavior
+        self.tab_indents = None
+        self.tab_mode = True  # see CodeEditor.set_tab_mode
+
+        # Intelligent backspace mode
+        self.intelligent_backspace = True
+
+        # Automatic (on the fly) completions
+        self.automatic_completions = True
+        self.automatic_completions_after_chars = 3
+
+        # Completions hint
+        self.completions_hint = True
+        self.completions_hint_after_ms = 500
+
+        self.close_parentheses_enabled = True
+        self.close_quotes_enabled = False
+        self.add_colons_enabled = True
+        self.auto_unindent_enabled = True
+
+        # Mouse tracking
+        self.setMouseTracking(True)
+        self.__cursor_changed = False
+        self._mouse_left_button_pressed = False
+        self.ctrl_click_color = QColor(Qt.blue)
+
+        self._bookmarks_blocks = {}
+        self.bookmarks = []
+
+        # Keyboard shortcuts
+        self.shortcuts = self.create_shortcuts()
+
+        # Paint event
+        self.__visible_blocks = []  # Visible blocks, update with repaint
+        self.painted.connect(self._draw_editor_cell_divider)
+
+        # Line stripping
+        self.last_change_position = None
+        self.last_position = None
+        self.last_auto_indent = None
+        self.skip_rstrip = False
+        self.strip_trailing_spaces_on_modify = True
+
+        # Hover hints
+        self.hover_hints_enabled = None
+
+        # Editor Extensions
+        self.editor_extensions = EditorExtensionsManager(self)
+        self.editor_extensions.add(CloseQuotesExtension())
+        self.editor_extensions.add(SnippetsExtension())
+        self.editor_extensions.add(CloseBracketsExtension())
+
+        # Some events should not be triggered during undo/redo
+        # such as line stripping
+        self.is_undoing = False
+        self.is_redoing = False
+
+        # Timer to Avoid too many calls to rehighlight.
+        self._rehighlight_timer = QTimer(self)
+        self._rehighlight_timer.setSingleShot(True)
+        self._rehighlight_timer.setInterval(150)
+
+    # ---- Hover/Hints
+    # -------------------------------------------------------------------------
+    def _should_display_hover(self, point):
+        """Check if a hover hint should be displayed:"""
+        if not self._mouse_left_button_pressed:
+            return (self.hover_hints_enabled and point
+                    and self.get_word_at(point))
+
+    def _handle_hover(self):
+        """Handle hover hint trigger after delay."""
+        self._timer_mouse_moving.stop()
+        pos = self._last_point
+
+        # These are textual characters but should not trigger a completion
+        # FIXME: update per language
+        ignore_chars = ['(', ')', '.']
+
+        if self._should_display_hover(pos):
+            key, pattern_text, cursor = self.get_pattern_at(pos)
+            text = self.get_word_at(pos)
+            if pattern_text:
+                ctrl_text = 'Cmd' if sys.platform == "darwin" else 'Ctrl'
+                if key in ['file']:
+                    hint_text = ctrl_text + ' + ' + _('click to open file')
+                elif key in ['mail']:
+                    hint_text = ctrl_text + ' + ' + _('click to send email')
+                elif key in ['url']:
+                    hint_text = ctrl_text + ' + ' + _('click to open url')
+                else:
+                    hint_text = ctrl_text + ' + ' + _('click to open')
+
+                hint_text = '<span>&nbsp;{}&nbsp;</span>'.format(hint_text)
+
+                self.show_tooltip(text=hint_text, at_point=pos)
+                return
+
+            cursor = self.cursorForPosition(pos)
+            cursor_offset = cursor.position()
+            line, col = cursor.blockNumber(), cursor.columnNumber()
+            self._last_point = pos
+            if text and self._last_hover_word != text:
+                if all(char not in text for char in ignore_chars):
+                    self._last_hover_word = text
+                    self.request_hover(line, col, cursor_offset)
+                else:
+                    self.hide_tooltip()
+        elif not self.is_completion_widget_visible():
+            self.hide_tooltip()
+
+    def blockuserdata_list(self):
+        """Get the list of all user data in document."""
+        block = self.document().firstBlock()
+        while block.isValid():
+            data = block.userData()
+            if data:
+                yield data
+            block = block.next()
+
+    def outlineexplorer_data_list(self):
+        """Get the list of all user data in document."""
+        for data in self.blockuserdata_list():
+            if data.oedata:
+                yield data.oedata
+
+    # ---- Keyboard Shortcuts
+    # -------------------------------------------------------------------------
+    def create_cursor_callback(self, attr):
+        """Make a callback for cursor move event type, (e.g. "Start")"""
+        def cursor_move_event():
+            cursor = self.textCursor()
+            move_type = getattr(QTextCursor, attr)
+            cursor.movePosition(move_type)
+            self.setTextCursor(cursor)
+        return cursor_move_event
+
+    def create_shortcuts(self):
+        """Create the local shortcuts for the CodeEditor."""
+        shortcut_context_name_callbacks = (
+            ('editor', 'code completion', self.do_completion),
+            ('editor', 'duplicate line down', self.duplicate_line_down),
+            ('editor', 'duplicate line up', self.duplicate_line_up),
+            ('editor', 'delete line', self.delete_line),
+            ('editor', 'move line up', self.move_line_up),
+            ('editor', 'move line down', self.move_line_down),
+            ('editor', 'go to new line', self.go_to_new_line),
+            ('editor', 'go to definition', self.go_to_definition_from_cursor),
+            ('editor', 'toggle comment', self.toggle_comment),
+            ('editor', 'blockcomment', self.blockcomment),
+            ('editor', 'create_new_cell', self.create_new_cell),
+            ('editor', 'unblockcomment', self.unblockcomment),
+            ('editor', 'transform to uppercase', self.transform_to_uppercase),
+            ('editor', 'transform to lowercase', self.transform_to_lowercase),
+            ('editor', 'indent', lambda: self.indent(force=True)),
+            ('editor', 'unindent', lambda: self.unindent(force=True)),
+            ('editor', 'start of line',
+             self.create_cursor_callback('StartOfLine')),
+            ('editor', 'end of line',
+             self.create_cursor_callback('EndOfLine')),
+            ('editor', 'previous line', self.create_cursor_callback('Up')),
+            ('editor', 'next line', self.create_cursor_callback('Down')),
+            ('editor', 'previous char', self.create_cursor_callback('Left')),
+            ('editor', 'next char', self.create_cursor_callback('Right')),
+            ('editor', 'previous word',
+             self.create_cursor_callback('PreviousWord')),
+            ('editor', 'next word', self.create_cursor_callback('NextWord')),
+            ('editor', 'kill to line end', self.kill_line_end),
+            ('editor', 'kill to line start', self.kill_line_start),
+            ('editor', 'yank', self._kill_ring.yank),
+            ('editor', 'rotate kill ring', self._kill_ring.rotate),
+            ('editor', 'kill previous word', self.kill_prev_word),
+            ('editor', 'kill next word', self.kill_next_word),
+            ('editor', 'start of document',
+             self.create_cursor_callback('Start')),
+            ('editor', 'end of document',
+             self.create_cursor_callback('End')),
+            ('editor', 'undo', self.undo),
+            ('editor', 'redo', self.redo),
+            ('editor', 'cut', self.cut),
+            ('editor', 'copy', self.copy),
+            ('editor', 'paste', self.paste),
+            ('editor', 'delete', self.delete),
+            ('editor', 'select all', self.selectAll),
+            ('editor', 'docstring',
+             self.writer_docstring.write_docstring_for_shortcut),
+            ('editor', 'autoformatting', self.format_document_or_range),
+            ('array_builder', 'enter array inline', self.enter_array_inline),
+            ('array_builder', 'enter array table', self.enter_array_table),
+            ('editor', 'scroll line down', self.scroll_line_down),
+            ('editor', 'scroll line up', self.scroll_line_up)
+            )
+
+        shortcuts = []
+        for context, name, callback in shortcut_context_name_callbacks:
+            shortcuts.append(
+                self.config_shortcut(
+                    callback, context=context, name=name, parent=self))
+        return shortcuts
+
+    def get_shortcut_data(self):
+        """
+        Returns shortcut data, a list of tuples (shortcut, text, default)
+        shortcut (QShortcut or QAction instance)
+        text (string): action/shortcut description
+        default (string): default key sequence
+        """
+        return [sc.data for sc in self.shortcuts]
+
+    def closeEvent(self, event):
+        if isinstance(self.highlighter, sh.PygmentsSH):
+            self.highlighter.stop()
+        self.update_folding_thread.quit()
+        self.update_folding_thread.wait()
+        self.update_diagnostics_thread.quit()
+        self.update_diagnostics_thread.wait()
+        TextEditBaseWidget.closeEvent(self, event)
+
+    def get_document_id(self):
+        return self.document_id
+
+    def set_as_clone(self, editor):
+        """Set as clone editor"""
+        self.setDocument(editor.document())
+        self.document_id = editor.get_document_id()
+        self.highlighter = editor.highlighter
+        self._rehighlight_timer.timeout.connect(
+            self.highlighter.rehighlight)
+        self.eol_chars = editor.eol_chars
+        self._apply_highlighter_color_scheme()
+        self.highlighter.sig_font_changed.connect(self.sync_font)
+
+    # ---- Widget setup and options
+    # -------------------------------------------------------------------------
+    def toggle_wrap_mode(self, enable):
+        """Enable/disable wrap mode"""
+        self.set_wrap_mode('word' if enable else None)
+
+    def toggle_line_numbers(self, linenumbers=True, markers=False):
+        """Enable/disable line numbers."""
+        self.linenumberarea.setup_margins(linenumbers, markers)
+
+    @property
+    def panels(self):
+        """
+        Returns a reference to the
+        :class:`spyder.widgets.panels.managers.PanelsManager`
+        used to manage the collection of installed panels
+        """
+        return self._panels
+
+    def setup_editor(self,
+                     linenumbers=True,
+                     language=None,
+                     markers=False,
+                     font=None,
+                     color_scheme=None,
+                     wrap=False,
+                     tab_mode=True,
+                     strip_mode=False,
+                     intelligent_backspace=True,
+                     automatic_completions=True,
+                     automatic_completions_after_chars=3,
+                     completions_hint=True,
+                     completions_hint_after_ms=500,
+                     hover_hints=True,
+                     code_snippets=True,
+                     highlight_current_line=True,
+                     highlight_current_cell=True,
+                     occurrence_highlighting=True,
+                     scrollflagarea=True,
+                     edge_line=True,
+                     edge_line_columns=(79,),
+                     show_blanks=False,
+                     underline_errors=False,
+                     close_parentheses=True,
+                     close_quotes=False,
+                     add_colons=True,
+                     auto_unindent=True,
+                     indent_chars=" "*4,
+                     tab_stop_width_spaces=4,
+                     cloned_from=None,
+                     filename=None,
+                     occurrence_timeout=1500,
+                     show_class_func_dropdown=False,
+                     indent_guides=False,
+                     scroll_past_end=False,
+                     folding=True,
+                     remove_trailing_spaces=False,
+                     remove_trailing_newlines=False,
+                     add_newline=False,
+                     format_on_save=False):
+        """
+        Set-up configuration for the CodeEditor instance.
+
+        Usually the parameters here are related with a configurable preference
+        in the Preference Dialog and Editor configurations:
+
+        linenumbers: Enable/Disable line number panel. Default True.
+        language: Set editor language for example python. Default None.
+        markers: Enable/Disable markers panel. Used to show elements like
+            Code Analysis. Default False.
+        font: Base font for the Editor to use. Default None.
+        color_scheme: Initial color scheme for the Editor to use. Default None.
+        wrap: Enable/Disable line wrap. Default False.
+        tab_mode: Enable/Disable using Tab as delimiter between word,
+            Default True.
+        strip_mode: strip_mode: Enable/Disable striping trailing spaces when
+            modifying the file. Default False.
+        intelligent_backspace: Enable/Disable automatically unindenting
+            inserted text (unindenting happens if the leading text length of
+            the line isn't module of the length of indentation chars being use)
+            Default True.
+        automatic_completions: Enable/Disable automatic completions.
+            The behavior of the trigger of this the completions can be
+            established with the two following kwargs. Default True.
+        automatic_completions_after_chars: Number of charts to type to trigger
+            an automatic completion. Default 3.
+        completions_hint: Enable/Disable documentation hints for completions.
+            Default True.
+        completions_hint_after_ms: Number of milliseconds over a completion
+            item to show the documentation hint. Default 500.
+        hover_hints: Enable/Disable documentation hover hints. Default True.
+        code_snippets: Enable/Disable code snippets completions. Default True.
+        highlight_current_line: Enable/Disable current line highlighting.
+            Default True.
+        highlight_current_cell: Enable/Disable current cell highlighting.
+            Default True.
+        occurrence_highlighting: Enable/Disable highlighting of current word
+            occurrence in the file. Default True.
+        scrollflagarea : Enable/Disable flag area that shows at the left of
+            the scroll bar. Default True.
+        edge_line: Enable/Disable vertical line to show max number of
+            characters per line. Customizable number of columns in the
+            following kwarg. Default True.
+        edge_line_columns: Number of columns/characters where the editor
+            horizontal edge line will show. Default (79,).
+        show_blanks: Enable/Disable blanks highlighting. Default False.
+        underline_errors: Enable/Disable showing and underline to highlight
+            errors. Default False.
+        close_parentheses: Enable/Disable automatic parentheses closing
+            insertion. Default True.
+        close_quotes: Enable/Disable automatic closing of quotes.
+            Default False.
+        add_colons: Enable/Disable automatic addition of colons. Default True.
+        auto_unindent: Enable/Disable automatically unindentation before else,
+            elif, finally or except statements. Default True.
+        indent_chars: Characters to use for indentation. Default " "*4.
+        tab_stop_width_spaces: Enable/Disable using tabs for indentation.
+            Default 4.
+        cloned_from: Editor instance used as template to instantiate this
+            CodeEditor instance. Default None.
+        filename: Initial filename to show. Default None.
+        occurrence_timeout : Timeout in milliseconds to start highlighting
+            matches/occurrences for the current word under the cursor.
+            Default 1500 ms.
+        show_class_func_dropdown: Enable/Disable a Matlab like widget to show
+            classes and functions available in the current file. Default False.
+        indent_guides: Enable/Disable highlighting of code indentation.
+            Default False.
+        scroll_past_end: Enable/Disable possibility to scroll file passed
+            its end. Default False.
+        folding: Enable/Disable code folding. Default True.
+        remove_trailing_spaces: Remove trailing whitespaces on lines.
+            Default False.
+        remove_trailing_newlines: Remove extra lines at the end of the file.
+            Default False.
+        add_newline: Add a newline at the end of the file if there is not one.
+            Default False.
+        format_on_save: Autoformat file automatically when saving.
+            Default False.
+        """
+
+        self.set_close_parentheses_enabled(close_parentheses)
+        self.set_close_quotes_enabled(close_quotes)
+        self.set_add_colons_enabled(add_colons)
+        self.set_auto_unindent_enabled(auto_unindent)
+        self.set_indent_chars(indent_chars)
+
+        # Show/hide folding panel depending on parameter
+        self.toggle_code_folding(folding)
+
+        # Scrollbar flag area
+        self.scrollflagarea.set_enabled(scrollflagarea)
+
+        # Edge line
+        self.edge_line.set_enabled(edge_line)
+        self.edge_line.set_columns(edge_line_columns)
+
+        # Indent guides
+        self.toggle_identation_guides(indent_guides)
+        if self.indent_chars == '\t':
+            self.indent_guides.set_indentation_width(
+                tab_stop_width_spaces)
+        else:
+            self.indent_guides.set_indentation_width(len(self.indent_chars))
+
+        # Blanks
+        self.set_blanks_enabled(show_blanks)
+
+        # Remove trailing whitespaces
+        self.set_remove_trailing_spaces(remove_trailing_spaces)
+
+        # Remove trailing newlines
+        self.set_remove_trailing_newlines(remove_trailing_newlines)
+
+        # Add newline at the end
+        self.set_add_newline(add_newline)
+
+        # Scrolling past the end
+        self.set_scrollpastend_enabled(scroll_past_end)
+
+        # Line number area and indent guides
+        self.toggle_line_numbers(linenumbers, markers)
+
+        # Lexer
+        self.filename = filename
+        self.set_language(language, filename)
+
+        # Underline errors and warnings
+        self.set_underline_errors_enabled(underline_errors)
+
+        # Highlight current cell
+        self.set_highlight_current_cell(highlight_current_cell)
+
+        # Highlight current line
+        self.set_highlight_current_line(highlight_current_line)
+
+        # Occurrence highlighting
+        self.set_occurrence_highlighting(occurrence_highlighting)
+        self.set_occurrence_timeout(occurrence_timeout)
+
+        # Tab always indents (even when cursor is not at the begin of line)
+        self.set_tab_mode(tab_mode)
+
+        # Intelligent backspace
+        self.toggle_intelligent_backspace(intelligent_backspace)
+
+        # Automatic completions
+        self.toggle_automatic_completions(automatic_completions)
+        self.set_automatic_completions_after_chars(
+            automatic_completions_after_chars)
+
+        # Completions hint
+        self.toggle_completions_hint(completions_hint)
+        self.set_completions_hint_after_ms(completions_hint_after_ms)
+
+        # Hover hints
+        self.toggle_hover_hints(hover_hints)
+
+        # Code snippets
+        self.toggle_code_snippets(code_snippets)
+
+        # Autoformat on save
+        self.toggle_format_on_save(format_on_save)
+
+        if cloned_from is not None:
+            self.is_cloned = True
+
+            # This is required for the line number area
+            self.setFont(font)
+
+            # Needed to show indent guides for splited editor panels
+            # See spyder-ide/spyder#10900
+            self.patch = cloned_from.patch
+
+            # Clone text and other properties
+            self.set_as_clone(cloned_from)
+
+            # Refresh panels
+            self.panels.refresh()
+        elif font is not None:
+            self.set_font(font, color_scheme)
+        elif color_scheme is not None:
+            self.set_color_scheme(color_scheme)
+
+        # Set tab spacing after font is set
+        self.set_tab_stop_width_spaces(tab_stop_width_spaces)
+
+        self.toggle_wrap_mode(wrap)
+
+        # Class/Function dropdown will be disabled if we're not in a Python
+        # file.
+        self.classfuncdropdown.setVisible(show_class_func_dropdown
+                                          and self.is_python_like())
+
+        self.set_strip_mode(strip_mode)
+
+    # ---- Debug panel
+    # -------------------------------------------------------------------------
+    # ---- Set different attributes
+    # -------------------------------------------------------------------------
+    def set_folding_panel(self, folding):
+        """Enable/disable folding panel."""
+        folding_panel = self.panels.get(FoldingPanel)
+        folding_panel.setVisible(folding)
+
+    def set_tab_mode(self, enable):
+        """
+        enabled = tab always indent
+        (otherwise tab indents only when cursor is at the beginning of a line)
+        """
+        self.tab_mode = enable
+
+    def set_strip_mode(self, enable):
+        """
+        Strip all trailing spaces if enabled.
+        """
+        self.strip_trailing_spaces_on_modify = enable
+
+    def toggle_intelligent_backspace(self, state):
+        self.intelligent_backspace = state
+
+    def toggle_automatic_completions(self, state):
+        self.automatic_completions = state
+
+    def toggle_hover_hints(self, state):
+        self.hover_hints_enabled = state
+
+    def toggle_code_snippets(self, state):
+        self.code_snippets = state
+
+    def toggle_format_on_save(self, state):
+        self.format_on_save = state
+
+    def toggle_code_folding(self, state):
+        self.code_folding = state
+        self.set_folding_panel(state)
+        if not state and self.indent_guides._enabled:
+            self.code_folding = True
+
+    def toggle_identation_guides(self, state):
+        if state and not self.code_folding:
+            self.code_folding = True
+        self.indent_guides.set_enabled(state)
+
+    def toggle_completions_hint(self, state):
+        """Enable/disable completion hint."""
+        self.completions_hint = state
+
+    def set_automatic_completions_after_chars(self, number):
+        """
+        Set the number of characters after which auto completion is fired.
+        """
+        self.automatic_completions_after_chars = number
+
+    def set_completions_hint_after_ms(self, ms):
+        """
+        Set the amount of time in ms after which the completions hint is shown.
+        """
+        self.completions_hint_after_ms = ms
+
+    def set_close_parentheses_enabled(self, enable):
+        """Enable/disable automatic parentheses insertion feature"""
+        self.close_parentheses_enabled = enable
+        bracket_extension = self.editor_extensions.get(CloseBracketsExtension)
+        if bracket_extension is not None:
+            bracket_extension.enabled = enable
+
+    def set_close_quotes_enabled(self, enable):
+        """Enable/disable automatic quote insertion feature"""
+        self.close_quotes_enabled = enable
+        quote_extension = self.editor_extensions.get(CloseQuotesExtension)
+        if quote_extension is not None:
+            quote_extension.enabled = enable
+
+    def set_add_colons_enabled(self, enable):
+        """Enable/disable automatic colons insertion feature"""
+        self.add_colons_enabled = enable
+
+    def set_auto_unindent_enabled(self, enable):
+        """Enable/disable automatic unindent after else/elif/finally/except"""
+        self.auto_unindent_enabled = enable
+
+    def set_occurrence_highlighting(self, enable):
+        """Enable/disable occurrence highlighting"""
+        self.occurrence_highlighting = enable
+        if not enable:
+            self.clear_occurrences()
+
+    def set_occurrence_timeout(self, timeout):
+        """Set occurrence highlighting timeout (ms)"""
+        self.occurrence_timer.setInterval(timeout)
+
+    def set_underline_errors_enabled(self, state):
+        """Toggle the underlining of errors and warnings."""
+        self.underline_errors_enabled = state
+        if not state:
+            self.clear_extra_selections('code_analysis_underline')
+
+    def set_highlight_current_line(self, enable):
+        """Enable/disable current line highlighting"""
+        self.highlight_current_line_enabled = enable
+        if self.highlight_current_line_enabled:
+            self.highlight_current_line()
+        else:
+            self.unhighlight_current_line()
+
+    def set_highlight_current_cell(self, enable):
+        """Enable/disable current line highlighting"""
+        hl_cell_enable = enable and self.supported_cell_language
+        self.highlight_current_cell_enabled = hl_cell_enable
+        if self.highlight_current_cell_enabled:
+            self.highlight_current_cell()
+        else:
+            self.unhighlight_current_cell()
+
+    def set_language(self, language, filename=None):
+        extra_supported_languages = {'stil': 'STIL'}
+        self.tab_indents = language in self.TAB_ALWAYS_INDENTS
+        self.comment_string = ''
+        self.language = 'Text'
+        self.supported_language = False
+        sh_class = sh.TextSH
+        language = 'None' if language is None else language
+        if language is not None:
+            for (key, value) in ALL_LANGUAGES.items():
+                if language.lower() in value:
+                    self.supported_language = True
+                    sh_class, comment_string = self.LANGUAGES[key]
+                    if key == 'IPython':
+                        self.language = 'Python'
+                    else:
+                        self.language = key
+                    self.comment_string = comment_string
+                    if key in CELL_LANGUAGES:
+                        self.supported_cell_language = True
+                        self.has_cell_separators = True
+                    break
+
+        if filename is not None and not self.supported_language:
+            sh_class = sh.guess_pygments_highlighter(filename)
+            self.support_language = sh_class is not sh.TextSH
+            if self.support_language:
+                # Pygments report S for the lexer name of R files
+                if sh_class._lexer.name == 'S':
+                    self.language = 'R'
+                else:
+                    self.language = sh_class._lexer.name
+            else:
+                _, ext = osp.splitext(filename)
+                ext = ext.lower()
+                if ext in extra_supported_languages:
+                    self.language = extra_supported_languages[ext]
+
+        self._set_highlighter(sh_class)
+        self.completion_widget.set_language(self.language)
+
+    def _set_highlighter(self, sh_class):
+        self.highlighter_class = sh_class
+        if self.highlighter is not None:
+            # Removing old highlighter
+            # TODO: test if leaving parent/document as is eats memory
+            self.highlighter.setParent(None)
+            self.highlighter.setDocument(None)
+        self.highlighter = self.highlighter_class(self.document(),
+                                                  self.font(),
+                                                  self.color_scheme)
+        self._apply_highlighter_color_scheme()
+
+        self.highlighter.editor = self
+        self.highlighter.sig_font_changed.connect(self.sync_font)
+        self._rehighlight_timer.timeout.connect(
+            self.highlighter.rehighlight)
+
+    def sync_font(self):
+        """Highlighter changed font, update."""
+        self.setFont(self.highlighter.font)
+        self.sig_font_changed.emit()
+
+    def get_cell_list(self):
+        """Get all cells."""
+        if self.highlighter is None:
+            return []
+
+        # Filter out old cells
+        def good(oedata):
+            return oedata.is_valid() and oedata.def_type == oedata.CELL
+
+        self.highlighter._cell_list = [
+            oedata for oedata in self.highlighter._cell_list if good(oedata)]
+
+        return sorted(
+            {oedata.block.blockNumber(): oedata
+             for oedata in self.highlighter._cell_list}.items())
+
+    def is_json(self):
+        return (isinstance(self.highlighter, sh.PygmentsSH) and
+                self.highlighter._lexer.name == 'JSON')
+
+    def is_python(self):
+        return self.highlighter_class is sh.PythonSH
+
+    def is_ipython(self):
+        return self.highlighter_class is sh.IPythonSH
+
+    def is_python_or_ipython(self):
+        return self.is_python() or self.is_ipython()
+
+    def is_cython(self):
+        return self.highlighter_class is sh.CythonSH
+
+    def is_enaml(self):
+        return self.highlighter_class is sh.EnamlSH
+
+    def is_python_like(self):
+        return (self.is_python() or self.is_ipython()
+                or self.is_cython() or self.is_enaml())
+
+    def intelligent_tab(self):
+        """Provide intelligent behavior for Tab key press."""
+        leading_text = self.get_text('sol', 'cursor')
+        if not leading_text.strip() or leading_text.endswith('#'):
+            # blank line or start of comment
+            self.indent_or_replace()
+        elif self.in_comment_or_string() and not leading_text.endswith(' '):
+            # in a word in a comment
+            self.do_completion()
+        elif leading_text.endswith('import ') or leading_text[-1] == '.':
+            # blank import or dot completion
+            self.do_completion()
+        elif (leading_text.split()[0] in ['from', 'import'] and
+                ';' not in leading_text):
+            # import line with a single statement
+            #  (prevents lines like: `import pdb; pdb.set_trace()`)
+            self.do_completion()
+        elif leading_text[-1] in '(,' or leading_text.endswith(', '):
+            self.indent_or_replace()
+        elif leading_text.endswith(' '):
+            # if the line ends with a space, indent
+            self.indent_or_replace()
+        elif re.search(r"[^\d\W]\w*\Z", leading_text, re.UNICODE):
+            # if the line ends with a non-whitespace character
+            self.do_completion()
+        else:
+            self.indent_or_replace()
+
+    def intelligent_backtab(self):
+        """Provide intelligent behavior for Shift+Tab key press"""
+        leading_text = self.get_text('sol', 'cursor')
+        if not leading_text.strip():
+            # blank line
+            self.unindent()
+        elif self.in_comment_or_string():
+            self.unindent()
+        elif leading_text[-1] in '(,' or leading_text.endswith(', '):
+            self.show_object_info()
+        else:
+            # if the line ends with any other character but comma
+            self.unindent()
+
+    def rehighlight(self):
+        """Rehighlight the whole document."""
+        if self.highlighter is not None:
+            self.highlighter.rehighlight()
+        if self.highlight_current_cell_enabled:
+            self.highlight_current_cell()
+        else:
+            self.unhighlight_current_cell()
+        if self.highlight_current_line_enabled:
+            self.highlight_current_line()
+        else:
+            self.unhighlight_current_line()
+
+    def trim_trailing_spaces(self):
+        """Remove trailing spaces"""
+        cursor = self.textCursor()
+        cursor.beginEditBlock()
+        cursor.movePosition(QTextCursor.Start)
+        while True:
+            cursor.movePosition(QTextCursor.EndOfBlock)
+            text = to_text_string(cursor.block().text())
+            length = len(text)-len(text.rstrip())
+            if length > 0:
+                cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor,
+                                    length)
+                cursor.removeSelectedText()
+            if cursor.atEnd():
+                break
+            cursor.movePosition(QTextCursor.NextBlock)
+        cursor.endEditBlock()
+
+    def trim_trailing_newlines(self):
+        """Remove extra newlines at the end of the document."""
+        cursor = self.textCursor()
+        cursor.beginEditBlock()
+        cursor.movePosition(QTextCursor.End)
+        line = cursor.blockNumber()
+        this_line = self.get_text_line(line)
+        previous_line = self.get_text_line(line - 1)
+
+        # Don't try to trim new lines for a file with a single line.
+        # Fixes spyder-ide/spyder#16401
+        if self.get_line_count() > 1:
+            while this_line == '':
+                cursor.movePosition(QTextCursor.PreviousBlock,
+                                    QTextCursor.KeepAnchor)
+
+                if self.add_newline:
+                    if this_line == '' and previous_line != '':
+                        cursor.movePosition(QTextCursor.NextBlock,
+                                            QTextCursor.KeepAnchor)
+
+                line -= 1
+                if line == 0:
+                    break
+
+                this_line = self.get_text_line(line)
+                previous_line = self.get_text_line(line - 1)
+
+        if not self.add_newline:
+            cursor.movePosition(QTextCursor.EndOfBlock,
+                                QTextCursor.KeepAnchor)
+
+        cursor.removeSelectedText()
+        cursor.endEditBlock()
+
+    def add_newline_to_file(self):
+        """Add a newline to the end of the file if it does not exist."""
+        cursor = self.textCursor()
+        cursor.movePosition(QTextCursor.End)
+        line = cursor.blockNumber()
+        this_line = self.get_text_line(line)
+        if this_line != '':
+            cursor.beginEditBlock()
+            cursor.movePosition(QTextCursor.EndOfBlock)
+            cursor.insertText(self.get_line_separator())
+            cursor.endEditBlock()
+
+    def fix_indentation(self):
+        """Replace tabs by spaces."""
+        text_before = to_text_string(self.toPlainText())
+        text_after = sourcecode.fix_indentation(text_before, self.indent_chars)
+        if text_before != text_after:
+            # We do the following rather than using self.setPlainText
+            # to benefit from QTextEdit's undo/redo feature.
+            self.selectAll()
+            self.skip_rstrip = True
+            self.insertPlainText(text_after)
+            self.skip_rstrip = False
+
+    def get_current_object(self):
+        """Return current object (string) """
+        source_code = to_text_string(self.toPlainText())
+        offset = self.get_position('cursor')
+        return sourcecode.get_primary_at(source_code, offset)
+
+    def next_cursor_position(self, position=None,
+                             mode=QTextLayout.SkipCharacters):
+        """
+        Get next valid cursor position.
+
+        Adapted from:
+        https://github.com/qt/qtbase/blob/5.15.2/src/gui/text/qtextdocument_p.cpp#L1361
+        """
+        cursor = self.textCursor()
+        if cursor.atEnd():
+            return position
+        if position is None:
+            position = cursor.position()
+        else:
+            cursor.setPosition(position)
+        it = cursor.block()
+        start = it.position()
+        end = start + it.length() - 1
+        if (position == end):
+            return end + 1
+        return it.layout().nextCursorPosition(position - start, mode) + start
+
+    @Slot()
+    def delete(self):
+        """Remove selected text or next character."""
+        if not self.has_selected_text():
+            cursor = self.textCursor()
+            if not cursor.atEnd():
+                cursor.setPosition(
+                    self.next_cursor_position(), QTextCursor.KeepAnchor)
+            self.setTextCursor(cursor)
+        self.remove_selected_text()
+
+    # ---- Scrolling
+    # -------------------------------------------------------------------------
+    def scroll_line_down(self):
+        """Scroll the editor down by one step."""
+        vsb = self.verticalScrollBar()
+        vsb.setValue(vsb.value() + vsb.singleStep())
+
+    def scroll_line_up(self):
+        """Scroll the editor up by one step."""
+        vsb = self.verticalScrollBar()
+        vsb.setValue(vsb.value() - vsb.singleStep())
+
+    # ---- Find occurrences
+    # -------------------------------------------------------------------------
+    def __find_first(self, text):
+        """Find first occurrence: scan whole document"""
+        flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords
+        cursor = self.textCursor()
+        # Scanning whole document
+        cursor.movePosition(QTextCursor.Start)
+        regexp = QRegularExpression(
+            r"\b%s\b" % QRegularExpression.escape(text)
+        )
+        cursor = self.document().find(regexp, cursor, flags)
+        self.__find_first_pos = cursor.position()
+        return cursor
+
+    def __find_next(self, text, cursor):
+        """Find next occurrence"""
+        flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords
+        regexp = QRegularExpression(
+            r"\b%s\b" % QRegularExpression.escape(text)
+        )
+        cursor = self.document().find(regexp, cursor, flags)
+        if cursor.position() != self.__find_first_pos:
+            return cursor
+
+    def __cursor_position_changed(self):
+        """Cursor position has changed"""
+        line, column = self.get_cursor_line_column()
+        self.sig_cursor_position_changed.emit(line, column)
+
+        if self.highlight_current_cell_enabled:
+            self.highlight_current_cell()
+        else:
+            self.unhighlight_current_cell()
+        if self.highlight_current_line_enabled:
+            self.highlight_current_line()
+        else:
+            self.unhighlight_current_line()
+        if self.occurrence_highlighting:
+            self.occurrence_timer.start()
+
+        # Strip if needed
+        self.strip_trailing_spaces()
+
+    def clear_occurrences(self):
+        """Clear occurrence markers"""
+        self.occurrences = []
+        self.clear_extra_selections('occurrences')
+        self.sig_flags_changed.emit()
+
+    def get_selection(self, cursor, foreground_color=None,
+                      background_color=None, underline_color=None,
+                      outline_color=None,
+                      underline_style=QTextCharFormat.SingleUnderline):
+        """Get selection."""
+        if cursor is None:
+            return
+
+        selection = TextDecoration(cursor)
+        if foreground_color is not None:
+            selection.format.setForeground(foreground_color)
+        if background_color is not None:
+            selection.format.setBackground(background_color)
+        if underline_color is not None:
+            selection.format.setProperty(QTextFormat.TextUnderlineStyle,
+                                         to_qvariant(underline_style))
+            selection.format.setProperty(QTextFormat.TextUnderlineColor,
+                                         to_qvariant(underline_color))
+        if outline_color is not None:
+            selection.set_outline(outline_color)
+        return selection
+
+    def highlight_selection(self, key, cursor, foreground_color=None,
+                            background_color=None, underline_color=None,
+                            outline_color=None,
+                            underline_style=QTextCharFormat.SingleUnderline):
+
+        selection = self.get_selection(
+            cursor, foreground_color, background_color, underline_color,
+            outline_color, underline_style)
+        if selection is None:
+            return
+        extra_selections = self.get_extra_selections(key)
+        extra_selections.append(selection)
+        self.set_extra_selections(key, extra_selections)
+
+    def mark_occurrences(self):
+        """Marking occurrences of the currently selected word"""
+        self.clear_occurrences()
+
+        if not self.supported_language:
+            return
+
+        text = self.get_selected_text().strip()
+        if not text:
+            text = self.get_current_word()
+        if text is None:
+            return
+        if (self.has_selected_text() and
+                self.get_selected_text().strip() != text):
+            return
+
+        if (self.is_python_like() and
+                (sourcecode.is_keyword(to_text_string(text)) or
+                 to_text_string(text) == 'self')):
+            return
+
+        # Highlighting all occurrences of word *text*
+        cursor = self.__find_first(text)
+        self.occurrences = []
+        extra_selections = self.get_extra_selections('occurrences')
+        first_occurrence = None
+        while cursor:
+            block = cursor.block()
+            if not block.userData():
+                # Add user data to check block validity
+                block.setUserData(BlockUserData(self))
+            self.occurrences.append(block)
+
+            selection = self.get_selection(cursor)
+            if len(selection.cursor.selectedText()) > 0:
+                extra_selections.append(selection)
+                if len(extra_selections) == 1:
+                    first_occurrence = selection
+                else:
+                    selection.format.setBackground(self.occurrence_color)
+                    first_occurrence.format.setBackground(
+                        self.occurrence_color)
+            cursor = self.__find_next(text, cursor)
+        self.set_extra_selections('occurrences', extra_selections)
+
+        if len(self.occurrences) > 1 and self.occurrences[-1] == 0:
+            # XXX: this is never happening with PySide but it's necessary
+            # for PyQt4... this must be related to a different behavior for
+            # the QTextDocument.find function between those two libraries
+            self.occurrences.pop(-1)
+        self.sig_flags_changed.emit()
+
+    # ---- Highlight found results
+    # -------------------------------------------------------------------------
+    def highlight_found_results(self, pattern, word=False, regexp=False,
+                                case=False):
+        """Highlight all found patterns"""
+        self.__find_args = {
+            'pattern': pattern,
+            'word': word,
+            'regexp': regexp,
+            'case': case,
+        }
+
+        pattern = to_text_string(pattern)
+        if not pattern:
+            return
+        if not regexp:
+            pattern = re.escape(to_text_string(pattern))
+        pattern = r"\b%s\b" % pattern if word else pattern
+        text = to_text_string(self.toPlainText())
+        re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE
+        try:
+            regobj = re.compile(pattern, flags=re_flags)
+        except sre_constants.error:
+            return
+        extra_selections = []
+        self.found_results = []
+        has_unicode = len(text) != qstring_length(text)
+        for match in regobj.finditer(text):
+            if has_unicode:
+                pos1, pos2 = sh.get_span(match)
+            else:
+                pos1, pos2 = match.span()
+            selection = TextDecoration(self.textCursor())
+            selection.format.setBackground(self.found_results_color)
+            selection.cursor.setPosition(pos1)
+
+            block = selection.cursor.block()
+            if not block.userData():
+                # Add user data to check block validity
+                block.setUserData(BlockUserData(self))
+            self.found_results.append(block)
+
+            selection.cursor.setPosition(pos2, QTextCursor.KeepAnchor)
+            extra_selections.append(selection)
+        self.set_extra_selections('find', extra_selections)
+
+    def clear_found_results(self):
+        """Clear found results highlighting"""
+        self.found_results = []
+        self.clear_extra_selections('find')
+        self.sig_flags_changed.emit()
+
+    def __text_has_changed(self):
+        """Text has changed, eventually clear found results highlighting"""
+        self.last_change_position = self.textCursor().position()
+
+        # If the change was on any of the lines were results were found,
+        # rehighlight them.
+        for result in self.found_results:
+            self.highlight_found_results(**self.__find_args)
+            break
+
+    def get_linenumberarea_width(self):
+        """
+        Return current line number area width.
+
+        This method is left for backward compatibility (BaseEditMixin
+        define it), any changes should be in LineNumberArea class.
+        """
+        return self.linenumberarea.get_width()
+
+    def calculate_real_position(self, point):
+        """Add offset to a point, to take into account the panels."""
+        point.setX(point.x() + self.panels.margin_size(Panel.Position.LEFT))
+        point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP))
+        return point
+
+    def calculate_real_position_from_global(self, point):
+        """Add offset to a point, to take into account the panels."""
+        point.setX(point.x() - self.panels.margin_size(Panel.Position.LEFT))
+        point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP))
+        return point
+
+    def get_linenumber_from_mouse_event(self, event):
+        """Return line number from mouse event"""
+        block = self.firstVisibleBlock()
+        line_number = block.blockNumber()
+        top = self.blockBoundingGeometry(block).translated(
+                                                    self.contentOffset()).top()
+        bottom = top + self.blockBoundingRect(block).height()
+        while block.isValid() and top < event.pos().y():
+            block = block.next()
+            if block.isVisible():  # skip collapsed blocks
+                top = bottom
+                bottom = top + self.blockBoundingRect(block).height()
+            line_number += 1
+        return line_number
+
+    def select_lines(self, linenumber_pressed, linenumber_released):
+        """Select line(s) after a mouse press/mouse press drag event"""
+        find_block_by_number = self.document().findBlockByNumber
+        move_n_blocks = (linenumber_released - linenumber_pressed)
+        start_line = linenumber_pressed
+        start_block = find_block_by_number(start_line - 1)
+
+        cursor = self.textCursor()
+        cursor.setPosition(start_block.position())
+
+        # Select/drag downwards
+        if move_n_blocks > 0:
+            for n in range(abs(move_n_blocks) + 1):
+                cursor.movePosition(cursor.NextBlock, cursor.KeepAnchor)
+        # Select/drag upwards or select single line
+        else:
+            cursor.movePosition(cursor.NextBlock)
+            for n in range(abs(move_n_blocks) + 1):
+                cursor.movePosition(cursor.PreviousBlock, cursor.KeepAnchor)
+
+        # Account for last line case
+        if linenumber_released == self.blockCount():
+            cursor.movePosition(cursor.EndOfBlock, cursor.KeepAnchor)
+        else:
+            cursor.movePosition(cursor.StartOfBlock, cursor.KeepAnchor)
+
+        self.setTextCursor(cursor)
+
+    # ---- Code bookmarks
+    # -------------------------------------------------------------------------
+    def add_bookmark(self, slot_num, line=None, column=None):
+        """Add bookmark to current block's userData."""
+        if line is None:
+            # Triggered by shortcut, else by spyder start
+            line, column = self.get_cursor_line_column()
+        block = self.document().findBlockByNumber(line)
+        data = block.userData()
+        if not data:
+            data = BlockUserData(self)
+        if slot_num not in data.bookmarks:
+            data.bookmarks.append((slot_num, column))
+        block.setUserData(data)
+        self._bookmarks_blocks[id(block)] = block
+        self.sig_bookmarks_changed.emit()
+
+    def get_bookmarks(self):
+        """Get bookmarks by going over all blocks."""
+        bookmarks = {}
+        pruned_bookmarks_blocks = {}
+        for block_id in self._bookmarks_blocks:
+            block = self._bookmarks_blocks[block_id]
+            if block.isValid():
+                data = block.userData()
+                if data and data.bookmarks:
+                    pruned_bookmarks_blocks[block_id] = block
+                    line_number = block.blockNumber()
+                    for slot_num, column in data.bookmarks:
+                        bookmarks[slot_num] = [line_number, column]
+        self._bookmarks_blocks = pruned_bookmarks_blocks
+        return bookmarks
+
+    def clear_bookmarks(self):
+        """Clear bookmarks for all blocks."""
+        self.bookmarks = {}
+        for data in self.blockuserdata_list():
+            data.bookmarks = []
+        self._bookmarks_blocks = {}
+
+    def set_bookmarks(self, bookmarks):
+        """Set bookmarks when opening file."""
+        self.clear_bookmarks()
+        for slot_num, bookmark in bookmarks.items():
+            self.add_bookmark(slot_num, bookmark[1], bookmark[2])
+
+    def update_bookmarks(self):
+        """Emit signal to update bookmarks."""
+        self.sig_bookmarks_changed.emit()
+
+    # ---- Code introspection
+    # -------------------------------------------------------------------------
+    def show_completion_object_info(self, name, signature):
+        """Trigger show completion info in Help Pane."""
+        force = True
+        self.sig_show_completion_object_info.emit(name, signature, force)
+
+    @Slot()
+    def show_object_info(self):
+        """Trigger a calltip"""
+        self.sig_show_object_info.emit(True)
+
+    # ---- Blank spaces
+    # -------------------------------------------------------------------------
+    def set_blanks_enabled(self, state):
+        """Toggle blanks visibility"""
+        self.blanks_enabled = state
+        option = self.document().defaultTextOption()
+        option.setFlags(option.flags() | \
+                        QTextOption.AddSpaceForLineAndParagraphSeparators)
+        if self.blanks_enabled:
+            option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces)
+        else:
+            option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces)
+        self.document().setDefaultTextOption(option)
+        # Rehighlight to make the spaces less apparent.
+        self.rehighlight()
+
+    def set_scrollpastend_enabled(self, state):
+        """
+        Allow user to scroll past the end of the document to have the last
+        line on top of the screen
+        """
+        self.scrollpastend_enabled = state
+        self.setCenterOnScroll(state)
+        self.setDocument(self.document())
+
+    def resizeEvent(self, event):
+        """Reimplemented Qt method to handle p resizing"""
+        TextEditBaseWidget.resizeEvent(self, event)
+        self.panels.resize()
+
+    def showEvent(self, event):
+        """Overrides showEvent to update the viewport margins."""
+        super(CodeEditor, self).showEvent(event)
+        self.panels.refresh()
+
+    # ---- Misc.
+    # -------------------------------------------------------------------------
+    def _apply_highlighter_color_scheme(self):
+        """Apply color scheme from syntax highlighter to the editor"""
+        hl = self.highlighter
+        if hl is not None:
+            self.set_palette(background=hl.get_background_color(),
+                             foreground=hl.get_foreground_color())
+            self.currentline_color = hl.get_currentline_color()
+            self.currentcell_color = hl.get_currentcell_color()
+            self.occurrence_color = hl.get_occurrence_color()
+            self.ctrl_click_color = hl.get_ctrlclick_color()
+            self.sideareas_color = hl.get_sideareas_color()
+            self.comment_color = hl.get_comment_color()
+            self.normal_color = hl.get_foreground_color()
+            self.matched_p_color = hl.get_matched_p_color()
+            self.unmatched_p_color = hl.get_unmatched_p_color()
+
+            self.edge_line.update_color()
+            self.indent_guides.update_color()
+
+            self.sig_theme_colors_changed.emit(
+                {'occurrence': self.occurrence_color})
+
+    def apply_highlighter_settings(self, color_scheme=None):
+        """Apply syntax highlighter settings"""
+        if self.highlighter is not None:
+            # Updating highlighter settings (font and color scheme)
+            self.highlighter.setup_formats(self.font())
+            if color_scheme is not None:
+                self.set_color_scheme(color_scheme)
+            else:
+                self._rehighlight_timer.start()
+
+    def set_font(self, font, color_scheme=None):
+        """Set font"""
+        # Note: why using this method to set color scheme instead of
+        #       'set_color_scheme'? To avoid rehighlighting the document twice
+        #       at startup.
+        if color_scheme is not None:
+            self.color_scheme = color_scheme
+        self.setFont(font)
+        self.panels.refresh()
+        self.apply_highlighter_settings(color_scheme)
+
+    def set_color_scheme(self, color_scheme):
+        """Set color scheme for syntax highlighting"""
+        self.color_scheme = color_scheme
+        if self.highlighter is not None:
+            # this calls self.highlighter.rehighlight()
+            self.highlighter.set_color_scheme(color_scheme)
+            self._apply_highlighter_color_scheme()
+        if self.highlight_current_cell_enabled:
+            self.highlight_current_cell()
+        else:
+            self.unhighlight_current_cell()
+        if self.highlight_current_line_enabled:
+            self.highlight_current_line()
+        else:
+            self.unhighlight_current_line()
+
+    def set_text(self, text):
+        """Set the text of the editor"""
+        self.setPlainText(text)
+        self.set_eol_chars(text=text)
+
+        if (isinstance(self.highlighter, sh.PygmentsSH)
+                and not running_under_pytest()):
+            self.highlighter.make_charlist()
+
+    def set_text_from_file(self, filename, language=None):
+        """Set the text of the editor from file *fname*"""
+        self.filename = filename
+        text, _enc = encoding.read(filename)
+        if language is None:
+            language = get_file_language(filename, text)
+        self.set_language(language, filename)
+        self.set_text(text)
+
+    def append(self, text):
+        """Append text to the end of the text widget"""
+        cursor = self.textCursor()
+        cursor.movePosition(QTextCursor.End)
+        cursor.insertText(text)
+
+    def adjust_indentation(self, line, indent_adjustment):
+        """Adjust indentation."""
+        if indent_adjustment == 0 or line == "":
+            return line
+        using_spaces = self.indent_chars != '\t'
+
+        if indent_adjustment > 0:
+            if using_spaces:
+                return ' ' * indent_adjustment + line
+            else:
+                return (
+                    self.indent_chars
+                    * (indent_adjustment // self.tab_stop_width_spaces)
+                    + line)
+
+        max_indent = self.get_line_indentation(line)
+        indent_adjustment = min(max_indent, -indent_adjustment)
+
+        indent_adjustment = (indent_adjustment if using_spaces else
+                             indent_adjustment // self.tab_stop_width_spaces)
+
+        return line[indent_adjustment:]
+
+    @Slot()
+    def paste(self):
+        """
+        Insert text or file/folder path copied from clipboard.
+
+        Reimplement QPlainTextEdit's method to fix the following issue:
+        on Windows, pasted text has only 'LF' EOL chars even if the original
+        text has 'CRLF' EOL chars.
+        The function also changes the clipboard data if they are copied as
+        files/folders but does not change normal text data except if they are
+        multiple lines. Since we are changing clipboard data we cannot use
+        paste, which directly pastes from clipboard instead we use
+        insertPlainText and pass the formatted/changed text without modifying
+        clipboard content.
+        """
+        clipboard = QApplication.clipboard()
+        text = to_text_string(clipboard.text())
+
+        if clipboard.mimeData().hasUrls():
+            # Have copied file and folder urls pasted as text paths.
+            # See spyder-ide/spyder#8644 for details.
+            urls = clipboard.mimeData().urls()
+            if all([url.isLocalFile() for url in urls]):
+                if len(urls) > 1:
+                    sep_chars = ',' + self.get_line_separator()
+                    text = sep_chars.join('"' + url.toLocalFile().
+                                          replace(osp.os.sep, '/')
+                                          + '"' for url in urls)
+                else:
+                    # The `urls` list can be empty, so we need to check that
+                    # before proceeding.
+                    # Fixes spyder-ide/spyder#17521
+                    if urls:
+                        text = urls[0].toLocalFile().replace(osp.os.sep, '/')
+
+        eol_chars = self.get_line_separator()
+        if len(text.splitlines()) > 1:
+            text = eol_chars.join((text + eol_chars).splitlines())
+
+        # Align multiline text based on first line
+        cursor = self.textCursor()
+        cursor.beginEditBlock()
+        cursor.removeSelectedText()
+        cursor.setPosition(cursor.selectionStart())
+        cursor.setPosition(cursor.block().position(),
+                           QTextCursor.KeepAnchor)
+        preceding_text = cursor.selectedText()
+        first_line_selected, *remaining_lines = (text + eol_chars).splitlines()
+        first_line = preceding_text + first_line_selected
+
+        first_line_adjustment = 0
+
+        # Dedent if automatic indentation makes code invalid
+        # Minimum indentation = max of current and paster indentation
+        if (self.is_python_like() and len(preceding_text.strip()) == 0
+                and len(first_line.strip()) > 0):
+            # Correct indentation
+            desired_indent = self.find_indentation()
+            if desired_indent:
+                # minimum indentation is either the current indentation
+                # or the indentation of the paster text
+                desired_indent = max(
+                    desired_indent,
+                    self.get_line_indentation(first_line_selected),
+                    self.get_line_indentation(preceding_text))
+                first_line_adjustment = (
+                    desired_indent - self.get_line_indentation(first_line))
+                # Only dedent, don't indent
+                first_line_adjustment = min(first_line_adjustment, 0)
+                # Only dedent, don't indent
+                first_line = self.adjust_indentation(
+                    first_line, first_line_adjustment)
+
+        # Fix indentation of multiline text based on first line
+        if len(remaining_lines) > 0 and len(first_line.strip()) > 0:
+            lines_adjustment = first_line_adjustment
+            lines_adjustment += CLIPBOARD_HELPER.remaining_lines_adjustment(
+                preceding_text)
+
+            # Make sure the code is not flattened
+            indentations = [
+                self.get_line_indentation(line)
+                for line in remaining_lines if line.strip() != ""]
+            if indentations:
+                max_dedent = min(indentations)
+                lines_adjustment = max(lines_adjustment, -max_dedent)
+            # Get new text
+            remaining_lines = [
+                self.adjust_indentation(line, lines_adjustment)
+                for line in remaining_lines]
+
+        text = eol_chars.join([first_line, *remaining_lines])
+
+        self.skip_rstrip = True
+        self.sig_will_paste_text.emit(text)
+        cursor.removeSelectedText()
+        cursor.insertText(text)
+        cursor.endEditBlock()
+        self.sig_text_was_inserted.emit()
+
+        self.skip_rstrip = False
+
+    def _save_clipboard_indentation(self):
+        """
+        Save the indentation corresponding to the clipboard data.
+
+        Must be called right after copying.
+        """
+        cursor = self.textCursor()
+        cursor.setPosition(cursor.selectionStart())
+        cursor.setPosition(cursor.block().position(),
+                           QTextCursor.KeepAnchor)
+        preceding_text = cursor.selectedText()
+        CLIPBOARD_HELPER.save_indentation(
+            preceding_text, self.tab_stop_width_spaces)
+
+    @Slot()
+    def cut(self):
+        """Reimplement cut to signal listeners about changes on the text."""
+        has_selected_text = self.has_selected_text()
+        if not has_selected_text:
+            return
+        start, end = self.get_selection_start_end()
+        self.sig_will_remove_selection.emit(start, end)
+        TextEditBaseWidget.cut(self)
+        self._save_clipboard_indentation()
+        self.sig_text_was_inserted.emit()
+
+    @Slot()
+    def copy(self):
+        """Reimplement copy to save indentation."""
+        TextEditBaseWidget.copy(self)
+        self._save_clipboard_indentation()
+
+    @Slot()
+    def undo(self):
+        """Reimplement undo to decrease text version number."""
+        if self.document().isUndoAvailable():
+            self.text_version -= 1
+            self.skip_rstrip = True
+            self.is_undoing = True
+            TextEditBaseWidget.undo(self)
+            self.sig_undo.emit()
+            self.sig_text_was_inserted.emit()
+            self.is_undoing = False
+            self.skip_rstrip = False
+
+    @Slot()
+    def redo(self):
+        """Reimplement redo to increase text version number."""
+        if self.document().isRedoAvailable():
+            self.text_version += 1
+            self.skip_rstrip = True
+            self.is_redoing = True
+            TextEditBaseWidget.redo(self)
+            self.sig_redo.emit()
+            self.sig_text_was_inserted.emit()
+            self.is_redoing = False
+            self.skip_rstrip = False
+
+    # ---- High-level editor features
+    # -------------------------------------------------------------------------
+    @Slot()
+    def center_cursor_on_next_focus(self):
+        """QPlainTextEdit's "centerCursor" requires the widget to be visible"""
+        self.centerCursor()
+        self.focus_in.disconnect(self.center_cursor_on_next_focus)
+
+    def go_to_line(self, line, start_column=0, end_column=0, word=''):
+        """Go to line number *line* and eventually highlight it"""
+        self.text_helper.goto_line(line, column=start_column,
+                                   end_column=end_column, move=True,
+                                   word=word)
+
+    def exec_gotolinedialog(self):
+        """Execute the GoToLineDialog dialog box"""
+        dlg = GoToLineDialog(self)
+        if dlg.exec_():
+            self.go_to_line(dlg.get_line_number())
+
+    def hide_tooltip(self):
+        """
+        Hide the tooltip widget.
+
+        The tooltip widget is a special QLabel that looks like a tooltip,
+        this method is here so it can be hidden as necessary. For example,
+        when the user leaves the Linenumber area when hovering over lint
+        warnings and errors.
+        """
+        self._timer_mouse_moving.stop()
+        self._last_hover_word = None
+        self.clear_extra_selections('code_analysis_highlight')
+        if self.tooltip_widget.isVisible():
+            self.tooltip_widget.hide()
+
+    def _set_completions_hint_idle(self):
+        self._completions_hint_idle = True
+        self.completion_widget.trigger_completion_hint()
+
+    def show_hint_for_completion(self, word, documentation, at_point):
+        """Show hint for completion element."""
+        if self.completions_hint and self._completions_hint_idle:
+            documentation = documentation.replace(u'\xa0', ' ')
+            completion_doc = {'name': word,
+                              'signature': documentation}
+
+            if documentation and len(documentation) > 0:
+                self.show_hint(
+                    documentation,
+                    inspect_word=word,
+                    at_point=at_point,
+                    completion_doc=completion_doc,
+                    max_lines=self._DEFAULT_MAX_LINES,
+                    max_width=self._DEFAULT_COMPLETION_HINT_MAX_WIDTH)
+                self.tooltip_widget.move(at_point)
+                return
+        self.hide_tooltip()
+
+    def update_decorations(self):
+        """Update decorations on the visible portion of the screen."""
+        if self.underline_errors_enabled:
+            self.underline_errors()
+
+        # This is required to update decorations whether there are or not
+        # underline errors in the visible portion of the screen.
+        # See spyder-ide/spyder#14268.
+        self.decorations.update()
+
+    def show_code_analysis_results(self, line_number, block_data):
+        """Show warning/error messages."""
+        # Diagnostic severity
+        icons = {
+            DiagnosticSeverity.ERROR: 'error',
+            DiagnosticSeverity.WARNING: 'warning',
+            DiagnosticSeverity.INFORMATION: 'information',
+            DiagnosticSeverity.HINT: 'hint',
+        }
+
+        code_analysis = block_data.code_analysis
+
+        # Size must be adapted from font
+        fm = self.fontMetrics()
+        size = fm.height()
+        template = (
+            '<img src="data:image/png;base64, {}"'
+            ' height="{size}" width="{size}" />&nbsp;'
+            '{} <i>({} {})</i>'
+        )
+
+        msglist = []
+        max_lines_msglist = 25
+        sorted_code_analysis = sorted(code_analysis, key=lambda i: i[2])
+        for src, code, sev, msg in sorted_code_analysis:
+            if src == 'pylint' and '[' in msg and ']' in msg:
+                # Remove extra redundant info from pylint messages
+                msg = msg.split(']')[-1]
+
+            msg = msg.strip()
+            # Avoid messing TODO, FIXME
+            # Prevent error if msg only has one element
+            if len(msg) > 1:
+                msg = msg[0].upper() + msg[1:]
+
+            # Get individual lines following paragraph format and handle
+            # symbols like '<' and '>' to not mess with br tags
+            msg = msg.replace('<', '&lt;').replace('>', '&gt;')
+            paragraphs = msg.splitlines()
+            new_paragraphs = []
+            long_paragraphs = 0
+            lines_per_message = 6
+            for paragraph in paragraphs:
+                new_paragraph = textwrap.wrap(
+                    paragraph,
+                    width=self._DEFAULT_MAX_HINT_WIDTH)
+                if lines_per_message > 2:
+                    if len(new_paragraph) > 1:
+                        new_paragraph = '<br>'.join(new_paragraph[:2]) + '...'
+                        long_paragraphs += 1
+                        lines_per_message -= 2
+                    else:
+                        new_paragraph = '<br>'.join(new_paragraph)
+                        lines_per_message -= 1
+                    new_paragraphs.append(new_paragraph)
+
+            if len(new_paragraphs) > 1:
+                # Define max lines taking into account that in the same
+                # tooltip you can find multiple warnings and messages
+                # and each one can have multiple lines
+                if long_paragraphs != 0:
+                    max_lines = 3
+                    max_lines_msglist -= max_lines * 2
+                else:
+                    max_lines = 5
+                    max_lines_msglist -= max_lines
+                msg = '<br>'.join(new_paragraphs[:max_lines]) + '<br>'
+            else:
+                msg = '<br>'.join(new_paragraphs)
+
+            base_64 = ima.base64_from_icon(icons[sev], size, size)
+            if max_lines_msglist >= 0:
+                msglist.append(template.format(base_64, msg, src,
+                                               code, size=size))
+
+        if msglist:
+            self.show_tooltip(
+                title=_("Code analysis"),
+                text='\n'.join(msglist),
+                title_color=QStylePalette.COLOR_ACCENT_4,
+                at_line=line_number,
+                with_html_format=True
+            )
+            self.highlight_line_warning(block_data)
+
+    def highlight_line_warning(self, block_data):
+        """Highlight errors and warnings in this editor."""
+        self.clear_extra_selections('code_analysis_highlight')
+        self.highlight_selection('code_analysis_highlight',
+                                 block_data._selection(),
+                                 background_color=block_data.color)
+        self.linenumberarea.update()
+
+    def get_current_warnings(self):
+        """
+        Get all warnings for the current editor and return
+        a list with the message and line number.
+        """
+        block = self.document().firstBlock()
+        line_count = self.document().blockCount()
+        warnings = []
+        while True:
+            data = block.userData()
+            if data and data.code_analysis:
+                for warning in data.code_analysis:
+                    warnings.append([warning[-1], block.blockNumber() + 1])
+            # See spyder-ide/spyder#9924
+            if block.blockNumber() + 1 == line_count:
+                break
+            block = block.next()
+        return warnings
+
+    def go_to_next_warning(self):
+        """
+        Go to next code warning message and return new cursor position.
+        """
+        block = self.textCursor().block()
+        line_count = self.document().blockCount()
+        for __ in range(line_count):
+            line_number = block.blockNumber() + 1
+            if line_number < line_count:
+                block = block.next()
+            else:
+                block = self.document().firstBlock()
+
+            data = block.userData()
+            if data and data.code_analysis:
+                line_number = block.blockNumber() + 1
+                self.go_to_line(line_number)
+                self.show_code_analysis_results(line_number, data)
+                return self.get_position('cursor')
+
+    def go_to_previous_warning(self):
+        """
+        Go to previous code warning message and return new cursor position.
+        """
+        block = self.textCursor().block()
+        line_count = self.document().blockCount()
+        for __ in range(line_count):
+            line_number = block.blockNumber() + 1
+            if line_number > 1:
+                block = block.previous()
+            else:
+                block = self.document().lastBlock()
+
+            data = block.userData()
+            if data and data.code_analysis:
+                line_number = block.blockNumber() + 1
+                self.go_to_line(line_number)
+                self.show_code_analysis_results(line_number, data)
+                return self.get_position('cursor')
+
+    def cell_list(self):
+        """Get the outline explorer data for all cells."""
+        for oedata in self.outlineexplorer_data_list():
+            if oedata.def_type == OED.CELL:
+                yield oedata
+
+    def get_cell_code(self, cell):
+        """
+        Get cell code for a given cell.
+
+        If the cell doesn't exist, raises an exception
+        """
+        selected_block = None
+        if is_string(cell):
+            for oedata in self.cell_list():
+                if oedata.def_name == cell:
+                    selected_block = oedata.block
+                    break
+        else:
+            if cell == 0:
+                selected_block = self.document().firstBlock()
+            else:
+                cell_list = list(self.cell_list())
+                if cell <= len(cell_list):
+                    selected_block = cell_list[cell - 1].block
+
+        if not selected_block:
+            raise RuntimeError("Cell {} not found.".format(repr(cell)))
+
+        cursor = QTextCursor(selected_block)
+        text, _, off_pos, col_pos = self.get_cell_as_executable_code(cursor)
+        return text
+
+    def get_cell_code_and_position(self, cell):
+        """
+        Get code and position for a given cell.
+
+        If the cell doesn't exist, raise an exception.
+        """
+        selected_block = None
+        if is_string(cell):
+            for oedata in self.cell_list():
+                if oedata.def_name == cell:
+                    selected_block = oedata.block
+                    break
+        else:
+            if cell == 0:
+                selected_block = self.document().firstBlock()
+            else:
+                cell_list = list(self.cell_list())
+                if cell <= len(cell_list):
+                    selected_block = cell_list[cell - 1].block
+
+        if not selected_block:
+            raise RuntimeError("Cell {} not found.".format(repr(cell)))
+
+        cursor = QTextCursor(selected_block)
+        text, _, off_pos, col_pos = self.get_cell_as_executable_code(cursor)
+        return text, off_pos, col_pos
+
+    def get_cell_count(self):
+        """Get number of cells in document."""
+        return 1 + len(list(self.cell_list()))
+
+    # ---- Tasks management
+    # -------------------------------------------------------------------------
+    def go_to_next_todo(self):
+        """Go to next todo and return new cursor position"""
+        block = self.textCursor().block()
+        line_count = self.document().blockCount()
+        while True:
+            if block.blockNumber()+1 < line_count:
+                block = block.next()
+            else:
+                block = self.document().firstBlock()
+            data = block.userData()
+            if data and data.todo:
+                break
+        line_number = block.blockNumber()+1
+        self.go_to_line(line_number)
+        self.show_tooltip(
+            title=_("To do"),
+            text=data.todo,
+            title_color=QStylePalette.COLOR_ACCENT_4,
+            at_line=line_number,
+        )
+
+        return self.get_position('cursor')
+
+    def process_todo(self, todo_results):
+        """Process todo finder results"""
+        for data in self.blockuserdata_list():
+            data.todo = ''
+
+        for message, line_number in todo_results:
+            block = self.document().findBlockByNumber(line_number - 1)
+            data = block.userData()
+            if not data:
+                data = BlockUserData(self)
+            data.todo = message
+            block.setUserData(data)
+        self.sig_flags_changed.emit()
+
+    # ---- Comments/Indentation
+    # -------------------------------------------------------------------------
+    def add_prefix(self, prefix):
+        """Add prefix to current line or selected line(s)"""
+        cursor = self.textCursor()
+        if self.has_selected_text():
+            # Add prefix to selected line(s)
+            start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
+
+            # Let's see if selection begins at a block start
+            first_pos = min([start_pos, end_pos])
+            first_cursor = self.textCursor()
+            first_cursor.setPosition(first_pos)
+
+            cursor.beginEditBlock()
+            cursor.setPosition(end_pos)
+            # Check if end_pos is at the start of a block: if so, starting
+            # changes from the previous block
+            if cursor.atBlockStart():
+                cursor.movePosition(QTextCursor.PreviousBlock)
+                if cursor.position() < start_pos:
+                    cursor.setPosition(start_pos)
+            move_number = self.__spaces_for_prefix()
+
+            while cursor.position() >= start_pos:
+                cursor.movePosition(QTextCursor.StartOfBlock)
+                line_text = to_text_string(cursor.block().text())
+                if (self.get_character(cursor.position()) == ' '
+                        and '#' in prefix and not line_text.isspace()
+                        or (not line_text.startswith(' ')
+                            and line_text != '')):
+                    cursor.movePosition(QTextCursor.Right,
+                                        QTextCursor.MoveAnchor,
+                                        move_number)
+                    cursor.insertText(prefix)
+                elif '#' not in prefix:
+                    cursor.insertText(prefix)
+                if cursor.blockNumber() == 0:
+                    # Avoid infinite loop when indenting the very first line
+                    break
+                cursor.movePosition(QTextCursor.PreviousBlock)
+                cursor.movePosition(QTextCursor.EndOfBlock)
+            cursor.endEditBlock()
+        else:
+            # Add prefix to current line
+            cursor.beginEditBlock()
+            cursor.movePosition(QTextCursor.StartOfBlock)
+            if self.get_character(cursor.position()) == ' ' and '#' in prefix:
+                cursor.movePosition(QTextCursor.NextWord)
+            cursor.insertText(prefix)
+            cursor.endEditBlock()
+
+    def __spaces_for_prefix(self):
+        """Find the less indented level of text."""
+        cursor = self.textCursor()
+        if self.has_selected_text():
+            # Add prefix to selected line(s)
+            start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
+
+            # Let's see if selection begins at a block start
+            first_pos = min([start_pos, end_pos])
+            first_cursor = self.textCursor()
+            first_cursor.setPosition(first_pos)
+
+            cursor.beginEditBlock()
+            cursor.setPosition(end_pos)
+            # Check if end_pos is at the start of a block: if so, starting
+            # changes from the previous block
+            if cursor.atBlockStart():
+                cursor.movePosition(QTextCursor.PreviousBlock)
+                if cursor.position() < start_pos:
+                    cursor.setPosition(start_pos)
+
+            number_spaces = -1
+            while cursor.position() >= start_pos:
+                cursor.movePosition(QTextCursor.StartOfBlock)
+                line_text = to_text_string(cursor.block().text())
+                start_with_space = line_text.startswith(' ')
+                left_number_spaces = self.__number_of_spaces(line_text)
+                if not start_with_space:
+                    left_number_spaces = 0
+                if ((number_spaces == -1
+                        or number_spaces > left_number_spaces)
+                        and not line_text.isspace() and line_text != ''):
+                    number_spaces = left_number_spaces
+                if cursor.blockNumber() == 0:
+                    # Avoid infinite loop when indenting the very first line
+                    break
+                cursor.movePosition(QTextCursor.PreviousBlock)
+                cursor.movePosition(QTextCursor.EndOfBlock)
+            cursor.endEditBlock()
+        return number_spaces
+
+    def remove_suffix(self, suffix):
+        """
+        Remove suffix from current line (there should not be any selection)
+        """
+        cursor = self.textCursor()
+        cursor.setPosition(cursor.position() - qstring_length(suffix),
+                           QTextCursor.KeepAnchor)
+        if to_text_string(cursor.selectedText()) == suffix:
+            cursor.removeSelectedText()
+
+    def remove_prefix(self, prefix):
+        """Remove prefix from current line or selected line(s)"""
+        cursor = self.textCursor()
+        if self.has_selected_text():
+            # Remove prefix from selected line(s)
+            start_pos, end_pos = sorted([cursor.selectionStart(),
+                                         cursor.selectionEnd()])
+            cursor.setPosition(start_pos)
+            if not cursor.atBlockStart():
+                cursor.movePosition(QTextCursor.StartOfBlock)
+                start_pos = cursor.position()
+            cursor.beginEditBlock()
+            cursor.setPosition(end_pos)
+            # Check if end_pos is at the start of a block: if so, starting
+            # changes from the previous block
+            if cursor.atBlockStart():
+                cursor.movePosition(QTextCursor.PreviousBlock)
+                if cursor.position() < start_pos:
+                    cursor.setPosition(start_pos)
+
+            cursor.movePosition(QTextCursor.StartOfBlock)
+            old_pos = None
+            while cursor.position() >= start_pos:
+                new_pos = cursor.position()
+                if old_pos == new_pos:
+                    break
+                else:
+                    old_pos = new_pos
+                line_text = to_text_string(cursor.block().text())
+                self.__remove_prefix(prefix, cursor, line_text)
+                cursor.movePosition(QTextCursor.PreviousBlock)
+            cursor.endEditBlock()
+        else:
+            # Remove prefix from current line
+            cursor.movePosition(QTextCursor.StartOfBlock)
+            line_text = to_text_string(cursor.block().text())
+            self.__remove_prefix(prefix, cursor, line_text)
+
+    def __remove_prefix(self, prefix, cursor, line_text):
+        """Handle the removal of the prefix for a single line."""
+        cursor.movePosition(QTextCursor.Right,
+                            QTextCursor.MoveAnchor,
+                            line_text.find(prefix))
+        # Handle prefix remove for comments with spaces
+        if (prefix.strip() and line_text.lstrip().startswith(prefix + ' ')
+                or line_text.startswith(prefix + ' ') and '#' in prefix):
+            cursor.movePosition(QTextCursor.Right,
+                                QTextCursor.KeepAnchor, len(prefix + ' '))
+        # Check for prefix without space
+        elif (prefix.strip() and line_text.lstrip().startswith(prefix)
+                or line_text.startswith(prefix)):
+            cursor.movePosition(QTextCursor.Right,
+                                QTextCursor.KeepAnchor, len(prefix))
+        cursor.removeSelectedText()
+
+    def __even_number_of_spaces(self, line_text, group=0):
+        """
+        Get if there is a correct indentation from a group of spaces of a line.
+        """
+        spaces = re.findall(r'\s+', line_text)
+        if len(spaces) - 1 >= group:
+            return len(spaces[group]) % len(self.indent_chars) == 0
+
+    def __number_of_spaces(self, line_text, group=0):
+        """Get the number of spaces from a group of spaces in a line."""
+        spaces = re.findall(r'\s+', line_text)
+        if len(spaces) - 1 >= group:
+            return len(spaces[group])
+
+
+    def fix_indent(self, *args, **kwargs):
+        """Indent line according to the preferences"""
+        if self.is_python_like():
+            ret = self.fix_indent_smart(*args, **kwargs)
+        else:
+            ret = self.simple_indentation(*args, **kwargs)
+        return ret
+
+    def simple_indentation(self, forward=True, **kwargs):
+        """
+        Simply preserve the indentation-level of the previous line.
+        """
+        cursor = self.textCursor()
+        block_nb = cursor.blockNumber()
+        prev_block = self.document().findBlockByNumber(block_nb - 1)
+        prevline = to_text_string(prev_block.text())
+
+        indentation = re.match(r"\s*", prevline).group()
+        # Unident
+        if not forward:
+            indentation = indentation[len(self.indent_chars):]
+
+        cursor.insertText(indentation)
+        return False  # simple indentation don't fix indentation
+
+    def find_indentation(self, forward=True, comment_or_string=False,
+                         cur_indent=None):
+        """
+        Find indentation (Python only, no text selection)
+
+        forward=True: fix indent only if text is not enough indented
+                      (otherwise force indent)
+        forward=False: fix indent only if text is too much indented
+                       (otherwise force unindent)
+
+        comment_or_string: Do not adjust indent level for
+            unmatched opening brackets and keywords
+
+        max_blank_lines: maximum number of blank lines to search before giving
+            up
+
+        cur_indent: current indent. This is the indent before we started
+            processing. E.g. when returning, indent before rstrip.
+
+        Returns the indentation for the current line
+
+        Assumes self.is_python_like() to return True
+        """
+        cursor = self.textCursor()
+        block_nb = cursor.blockNumber()
+        # find the line that contains our scope
+        line_in_block = False
+        visual_indent = False
+        add_indent = 0  # How many levels of indent to add
+        prevline = None
+        prevtext = ""
+        empty_lines = True
+
+        closing_brackets = []
+        for prevline in range(block_nb - 1, -1, -1):
+            cursor.movePosition(QTextCursor.PreviousBlock)
+            prevtext = to_text_string(cursor.block().text()).rstrip()
+
+            bracket_stack, closing_brackets, comment_pos = self.__get_brackets(
+                prevtext, closing_brackets)
+
+            if not prevtext:
+                continue
+
+            if prevtext.endswith((':', '\\')):
+                # Presume a block was started
+                line_in_block = True  # add one level of indent to correct_indent
+                # Does this variable actually do *anything* of relevance?
+                # comment_or_string = True
+
+            if bracket_stack or not closing_brackets:
+                break
+
+            if prevtext.strip() != '':
+                empty_lines = False
+
+        if empty_lines and prevline is not None and prevline < block_nb - 2:
+            # The previous line is too far, ignore
+            prevtext = ''
+            prevline = block_nb - 2
+            line_in_block = False
+
+        # splits of prevtext happen a few times. Let's just do it once
+        words = re.split(r'[\s\(\[\{\}\]\)]', prevtext.lstrip())
+
+        if line_in_block:
+            add_indent += 1
+
+        if prevtext and not comment_or_string:
+            if bracket_stack:
+                # Hanging indent
+                if prevtext.endswith(('(', '[', '{')):
+                    add_indent += 1
+                    if words[0] in ('class', 'def', 'elif', 'except', 'for',
+                                    'if', 'while', 'with'):
+                        add_indent += 1
+                    elif not (  # I'm not sure this block should exist here
+                            (
+                                self.tab_stop_width_spaces
+                                if self.indent_chars == '\t' else
+                                len(self.indent_chars)
+                            ) * 2 < len(prevtext)):
+                        visual_indent = True
+                else:
+                    # There's stuff after unmatched opening brackets
+                    visual_indent = True
+            elif (words[-1] in ('continue', 'break', 'pass',)
+                  or words[0] == "return" and not line_in_block
+                  ):
+                add_indent -= 1
+
+        if prevline:
+            prevline_indent = self.get_block_indentation(prevline)
+        else:
+            prevline_indent = 0
+
+        if visual_indent:  # can only be true if bracket_stack
+            correct_indent = bracket_stack[-1][0] + 1
+        elif add_indent:
+            # Indent
+            if self.indent_chars == '\t':
+                correct_indent = prevline_indent + self.tab_stop_width_spaces * add_indent
+            else:
+                correct_indent = prevline_indent + len(self.indent_chars) * add_indent
+        else:
+            correct_indent = prevline_indent
+
+        # TODO untangle this block
+        if prevline and not bracket_stack and not prevtext.endswith(':'):
+            if forward:
+                # Keep indentation of previous line
+                ref_line = block_nb - 1
+            else:
+                # Find indentation context
+                ref_line = prevline
+            if cur_indent is None:
+                cur_indent = self.get_block_indentation(ref_line)
+            is_blank = not self.get_text_line(ref_line).strip()
+            trailing_text = self.get_text_line(block_nb).strip()
+            # If brackets are matched and no block gets opened
+            # Match the above line's indent and nudge to the next multiple of 4
+
+            if cur_indent < prevline_indent and (trailing_text or is_blank):
+                # if line directly above is blank or there is text after cursor
+                # Ceiling division
+                correct_indent = -(-cur_indent // len(self.indent_chars)) * \
+                    len(self.indent_chars)
+        return correct_indent
+
+    def fix_indent_smart(self, forward=True, comment_or_string=False,
+                         cur_indent=None):
+        """
+        Fix indentation (Python only, no text selection)
+
+        forward=True: fix indent only if text is not enough indented
+                      (otherwise force indent)
+        forward=False: fix indent only if text is too much indented
+                       (otherwise force unindent)
+
+        comment_or_string: Do not adjust indent level for
+            unmatched opening brackets and keywords
+
+        max_blank_lines: maximum number of blank lines to search before giving
+            up
+
+        cur_indent: current indent. This is the indent before we started
+            processing. E.g. when returning, indent before rstrip.
+
+        Returns True if indent needed to be fixed
+
+        Assumes self.is_python_like() to return True
+        """
+        cursor = self.textCursor()
+        block_nb = cursor.blockNumber()
+        indent = self.get_block_indentation(block_nb)
+
+        correct_indent = self.find_indentation(
+            forward, comment_or_string, cur_indent)
+
+        if correct_indent >= 0 and not (
+                indent == correct_indent or
+                forward and indent > correct_indent or
+                not forward and indent < correct_indent
+                ):
+            # Insert the determined indent
+            cursor = self.textCursor()
+            cursor.movePosition(QTextCursor.StartOfBlock)
+            if self.indent_chars == '\t':
+                indent = indent // self.tab_stop_width_spaces
+            cursor.setPosition(cursor.position()+indent, QTextCursor.KeepAnchor)
+            cursor.removeSelectedText()
+            if self.indent_chars == '\t':
+                indent_text = (
+                    '\t' * (correct_indent // self.tab_stop_width_spaces) +
+                    ' ' * (correct_indent % self.tab_stop_width_spaces)
+                )
+            else:
+                indent_text = ' '*correct_indent
+            cursor.insertText(indent_text)
+            return True
+        return False
+
+    @Slot()
+    def clear_all_output(self):
+        """Removes all output in the ipynb format (Json only)"""
+        try:
+            nb = nbformat.reads(self.toPlainText(), as_version=4)
+            if nb.cells:
+                for cell in nb.cells:
+                    if 'outputs' in cell:
+                        cell['outputs'] = []
+                    if 'prompt_number' in cell:
+                        cell['prompt_number'] = None
+            # We do the following rather than using self.setPlainText
+            # to benefit from QTextEdit's undo/redo feature.
+            self.selectAll()
+            self.skip_rstrip = True
+            self.insertPlainText(nbformat.writes(nb))
+            self.skip_rstrip = False
+        except Exception as e:
+            QMessageBox.critical(self, _('Removal error'),
+                           _("It was not possible to remove outputs from "
+                             "this notebook. The error is:\n\n") + \
+                             to_text_string(e))
+            return
+
+    @Slot()
+    def convert_notebook(self):
+        """Convert an IPython notebook to a Python script in editor"""
+        try:
+            nb = nbformat.reads(self.toPlainText(), as_version=4)
+            script = nbexporter().from_notebook_node(nb)[0]
+        except Exception as e:
+            QMessageBox.critical(self, _('Conversion error'),
+                                 _("It was not possible to convert this "
+                                 "notebook. The error is:\n\n") + \
+                                 to_text_string(e))
+            return
+        self.sig_new_file.emit(script)
+
+    def indent(self, force=False):
+        """
+        Indent current line or selection
+
+        force=True: indent even if cursor is not a the beginning of the line
+        """
+        leading_text = self.get_text('sol', 'cursor')
+        if self.has_selected_text():
+            self.add_prefix(self.indent_chars)
+        elif (force or not leading_text.strip() or
+                (self.tab_indents and self.tab_mode)):
+            if self.is_python_like():
+                if not self.fix_indent(forward=True):
+                    self.add_prefix(self.indent_chars)
+            else:
+                self.add_prefix(self.indent_chars)
+        else:
+            if len(self.indent_chars) > 1:
+                length = len(self.indent_chars)
+                self.insert_text(" "*(length-(len(leading_text) % length)))
+            else:
+                self.insert_text(self.indent_chars)
+
+    def indent_or_replace(self):
+        """Indent or replace by 4 spaces depending on selection and tab mode"""
+        if (self.tab_indents and self.tab_mode) or not self.has_selected_text():
+            self.indent()
+        else:
+            cursor = self.textCursor()
+            if (self.get_selected_text() ==
+                    to_text_string(cursor.block().text())):
+                self.indent()
+            else:
+                cursor1 = self.textCursor()
+                cursor1.setPosition(cursor.selectionStart())
+                cursor2 = self.textCursor()
+                cursor2.setPosition(cursor.selectionEnd())
+                if cursor1.blockNumber() != cursor2.blockNumber():
+                    self.indent()
+                else:
+                    self.replace(self.indent_chars)
+
+    def unindent(self, force=False):
+        """
+        Unindent current line or selection
+
+        force=True: unindent even if cursor is not a the beginning of the line
+        """
+        if self.has_selected_text():
+            if self.indent_chars == "\t":
+                # Tabs, remove one tab
+                self.remove_prefix(self.indent_chars)
+            else:
+                # Spaces
+                space_count = len(self.indent_chars)
+                leading_spaces = self.__spaces_for_prefix()
+                remainder = leading_spaces % space_count
+                if remainder:
+                    # Get block on "space multiple grid".
+                    # See spyder-ide/spyder#5734.
+                    self.remove_prefix(" "*remainder)
+                else:
+                    # Unindent one space multiple
+                    self.remove_prefix(self.indent_chars)
+        else:
+            leading_text = self.get_text('sol', 'cursor')
+            if (force or not leading_text.strip() or
+                    (self.tab_indents and self.tab_mode)):
+                if self.is_python_like():
+                    if not self.fix_indent(forward=False):
+                        self.remove_prefix(self.indent_chars)
+                elif leading_text.endswith('\t'):
+                    self.remove_prefix('\t')
+                else:
+                    self.remove_prefix(self.indent_chars)
+
+    @Slot()
+    def toggle_comment(self):
+        """Toggle comment on current line or selection"""
+        cursor = self.textCursor()
+        start_pos, end_pos = sorted([cursor.selectionStart(),
+                                     cursor.selectionEnd()])
+        cursor.setPosition(end_pos)
+        last_line = cursor.block().blockNumber()
+        if cursor.atBlockStart() and start_pos != end_pos:
+            last_line -= 1
+        cursor.setPosition(start_pos)
+        first_line = cursor.block().blockNumber()
+        # If the selection contains only commented lines and surrounding
+        # whitespace, uncomment. Otherwise, comment.
+        is_comment_or_whitespace = True
+        at_least_one_comment = False
+        for _line_nb in range(first_line, last_line+1):
+            text = to_text_string(cursor.block().text()).lstrip()
+            is_comment = text.startswith(self.comment_string)
+            is_whitespace = (text == '')
+            is_comment_or_whitespace *= (is_comment or is_whitespace)
+            if is_comment:
+                at_least_one_comment = True
+            cursor.movePosition(QTextCursor.NextBlock)
+        if is_comment_or_whitespace and at_least_one_comment:
+            self.uncomment()
+        else:
+            self.comment()
+
+    def is_comment(self, block):
+        """Detect inline comments.
+
+        Return True if the block is an inline comment.
+        """
+        if block is None:
+            return False
+        text = to_text_string(block.text()).lstrip()
+        return text.startswith(self.comment_string)
+
+    def comment(self):
+        """Comment current line or selection."""
+        self.add_prefix(self.comment_string + ' ')
+
+    def uncomment(self):
+        """Uncomment current line or selection."""
+        blockcomment = self.unblockcomment()
+        if not blockcomment:
+            self.remove_prefix(self.comment_string)
+
+    def __blockcomment_bar(self, compatibility=False):
+        """Handle versions of blockcomment bar for backwards compatibility."""
+        # Blockcomment bar in Spyder version >= 4
+        blockcomment_bar = self.comment_string + ' ' + '=' * (
+                                    79 - len(self.comment_string + ' '))
+        if compatibility:
+            # Blockcomment bar in Spyder version < 4
+            blockcomment_bar = self.comment_string + '=' * (
+                                    79 - len(self.comment_string))
+        return blockcomment_bar
+
+    def transform_to_uppercase(self):
+        """Change to uppercase current line or selection."""
+        cursor = self.textCursor()
+        prev_pos = cursor.position()
+        selected_text = to_text_string(cursor.selectedText())
+
+        if len(selected_text) == 0:
+            prev_pos = cursor.position()
+            cursor.select(QTextCursor.WordUnderCursor)
+            selected_text = to_text_string(cursor.selectedText())
+
+        s = selected_text.upper()
+        cursor.insertText(s)
+        self.set_cursor_position(prev_pos)
+
+    def transform_to_lowercase(self):
+        """Change to lowercase current line or selection."""
+        cursor = self.textCursor()
+        prev_pos = cursor.position()
+        selected_text = to_text_string(cursor.selectedText())
+
+        if len(selected_text) == 0:
+            prev_pos = cursor.position()
+            cursor.select(QTextCursor.WordUnderCursor)
+            selected_text = to_text_string(cursor.selectedText())
+
+        s = selected_text.lower()
+        cursor.insertText(s)
+        self.set_cursor_position(prev_pos)
+
+    def blockcomment(self):
+        """Block comment current line or selection."""
+        comline = self.__blockcomment_bar() + self.get_line_separator()
+        cursor = self.textCursor()
+        if self.has_selected_text():
+            self.extend_selection_to_complete_lines()
+            start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
+        else:
+            start_pos = end_pos = cursor.position()
+        cursor.beginEditBlock()
+        cursor.setPosition(start_pos)
+        cursor.movePosition(QTextCursor.StartOfBlock)
+        while cursor.position() <= end_pos:
+            cursor.insertText(self.comment_string + " ")
+            cursor.movePosition(QTextCursor.EndOfBlock)
+            if cursor.atEnd():
+                break
+            cursor.movePosition(QTextCursor.NextBlock)
+            end_pos += len(self.comment_string + " ")
+        cursor.setPosition(end_pos)
+        cursor.movePosition(QTextCursor.EndOfBlock)
+        if cursor.atEnd():
+            cursor.insertText(self.get_line_separator())
+        else:
+            cursor.movePosition(QTextCursor.NextBlock)
+        cursor.insertText(comline)
+        cursor.setPosition(start_pos)
+        cursor.movePosition(QTextCursor.StartOfBlock)
+        cursor.insertText(comline)
+        cursor.endEditBlock()
+
+    def unblockcomment(self):
+        """Un-block comment current line or selection."""
+        # Needed for backward compatibility with Spyder previous blockcomments.
+        # See spyder-ide/spyder#2845.
+        unblockcomment = self.__unblockcomment()
+        if not unblockcomment:
+            unblockcomment =  self.__unblockcomment(compatibility=True)
+        else:
+            return unblockcomment
+
+    def __unblockcomment(self, compatibility=False):
+        """Un-block comment current line or selection helper."""
+        def __is_comment_bar(cursor):
+            return to_text_string(cursor.block().text()
+                           ).startswith(
+                         self.__blockcomment_bar(compatibility=compatibility))
+        # Finding first comment bar
+        cursor1 = self.textCursor()
+        if __is_comment_bar(cursor1):
+            return
+        while not __is_comment_bar(cursor1):
+            cursor1.movePosition(QTextCursor.PreviousBlock)
+            if cursor1.blockNumber() == 0:
+                break
+        if not __is_comment_bar(cursor1):
+            return False
+
+        def __in_block_comment(cursor):
+            cs = self.comment_string
+            return to_text_string(cursor.block().text()).startswith(cs)
+        # Finding second comment bar
+        cursor2 = QTextCursor(cursor1)
+        cursor2.movePosition(QTextCursor.NextBlock)
+        while not __is_comment_bar(cursor2) and __in_block_comment(cursor2):
+            cursor2.movePosition(QTextCursor.NextBlock)
+            if cursor2.block() == self.document().lastBlock():
+                break
+        if not __is_comment_bar(cursor2):
+            return False
+        # Removing block comment
+        cursor3 = self.textCursor()
+        cursor3.beginEditBlock()
+        cursor3.setPosition(cursor1.position())
+        cursor3.movePosition(QTextCursor.NextBlock)
+        while cursor3.position() < cursor2.position():
+            cursor3.movePosition(QTextCursor.NextCharacter,
+                                 QTextCursor.KeepAnchor)
+            if not cursor3.atBlockEnd():
+                # standard commenting inserts '# ' but a trailing space on an
+                # empty line might be stripped.
+                if not compatibility:
+                    cursor3.movePosition(QTextCursor.NextCharacter,
+                                         QTextCursor.KeepAnchor)
+            cursor3.removeSelectedText()
+            cursor3.movePosition(QTextCursor.NextBlock)
+        for cursor in (cursor2, cursor1):
+            cursor3.setPosition(cursor.position())
+            cursor3.select(QTextCursor.BlockUnderCursor)
+            cursor3.removeSelectedText()
+        cursor3.endEditBlock()
+        return True
+
+    def create_new_cell(self):
+        firstline = '# %%' + self.get_line_separator()
+        endline = self.get_line_separator()
+        cursor = self.textCursor()
+        if self.has_selected_text():
+            self.extend_selection_to_complete_lines()
+            start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
+            endline = self.get_line_separator() + '# %%'
+        else:
+            start_pos = end_pos = cursor.position()
+
+        # Add cell comment or enclose current selection in cells
+        cursor.beginEditBlock()
+        cursor.setPosition(end_pos)
+        cursor.movePosition(QTextCursor.EndOfBlock)
+        cursor.insertText(endline)
+        cursor.setPosition(start_pos)
+        cursor.movePosition(QTextCursor.StartOfBlock)
+        cursor.insertText(firstline)
+        cursor.endEditBlock()
+        
+    # ---- Kill ring handlers
+    # Taken from Jupyter's QtConsole
+    # Copyright (c) 2001-2015, IPython Development Team
+    # Copyright (c) 2015-, Jupyter Development Team
+    # -------------------------------------------------------------------------
+    def kill_line_end(self):
+        """Kill the text on the current line from the cursor forward"""
+        cursor = self.textCursor()
+        cursor.clearSelection()
+        cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
+        if not cursor.hasSelection():
+            # Line deletion
+            cursor.movePosition(QTextCursor.NextBlock,
+                                QTextCursor.KeepAnchor)
+        self._kill_ring.kill_cursor(cursor)
+        self.setTextCursor(cursor)
+
+    def kill_line_start(self):
+        """Kill the text on the current line from the cursor backward"""
+        cursor = self.textCursor()
+        cursor.clearSelection()
+        cursor.movePosition(QTextCursor.StartOfBlock,
+                            QTextCursor.KeepAnchor)
+        self._kill_ring.kill_cursor(cursor)
+        self.setTextCursor(cursor)
+
+    def _get_word_start_cursor(self, position):
+        """Find the start of the word to the left of the given position. If a
+           sequence of non-word characters precedes the first word, skip over
+           them. (This emulates the behavior of bash, emacs, etc.)
+        """
+        document = self.document()
+        position -= 1
+        while (position and not
+               self.is_letter_or_number(document.characterAt(position))):
+            position -= 1
+        while position and self.is_letter_or_number(
+                document.characterAt(position)):
+            position -= 1
+        cursor = self.textCursor()
+        cursor.setPosition(self.next_cursor_position())
+        return cursor
+
+    def _get_word_end_cursor(self, position):
+        """Find the end of the word to the right of the given position. If a
+           sequence of non-word characters precedes the first word, skip over
+           them. (This emulates the behavior of bash, emacs, etc.)
+        """
+        document = self.document()
+        cursor = self.textCursor()
+        position = cursor.position()
+        cursor.movePosition(QTextCursor.End)
+        end = cursor.position()
+        while (position < end and
+               not self.is_letter_or_number(document.characterAt(position))):
+            position = self.next_cursor_position(position)
+        while (position < end and
+               self.is_letter_or_number(document.characterAt(position))):
+            position = self.next_cursor_position(position)
+        cursor.setPosition(position)
+        return cursor
+
+    def kill_prev_word(self):
+        """Kill the previous word"""
+        position = self.textCursor().position()
+        cursor = self._get_word_start_cursor(position)
+        cursor.setPosition(position, QTextCursor.KeepAnchor)
+        self._kill_ring.kill_cursor(cursor)
+        self.setTextCursor(cursor)
+
+    def kill_next_word(self):
+        """Kill the next word"""
+        position = self.textCursor().position()
+        cursor = self._get_word_end_cursor(position)
+        cursor.setPosition(position, QTextCursor.KeepAnchor)
+        self._kill_ring.kill_cursor(cursor)
+        self.setTextCursor(cursor)
+
+    # ---- Autoinsertion of quotes/colons
+    # -------------------------------------------------------------------------
+    def __get_current_color(self, cursor=None):
+        """Get the syntax highlighting color for the current cursor position"""
+        if cursor is None:
+            cursor = self.textCursor()
+
+        block = cursor.block()
+        pos = cursor.position() - block.position()  # relative pos within block
+        layout = block.layout()
+        block_formats = layout.formats()
+
+        if block_formats:
+            # To easily grab current format for autoinsert_colons
+            if cursor.atBlockEnd():
+                current_format = block_formats[-1].format
+            else:
+                current_format = None
+                for fmt in block_formats:
+                    if (pos >= fmt.start) and (pos < fmt.start + fmt.length):
+                        current_format = fmt.format
+                if current_format is None:
+                    return None
+            color = current_format.foreground().color().name()
+            return color
+        else:
+            return None
+
+    def in_comment_or_string(self, cursor=None, position=None):
+        """Is the cursor or position inside or next to a comment or string?
+
+        If *cursor* is None, *position* is used instead. If *position* is also
+        None, then the current cursor position is used.
+        """
+        if self.highlighter:
+            if cursor is None:
+                cursor = self.textCursor()
+                if position:
+                    cursor.setPosition(position)
+            current_color = self.__get_current_color(cursor=cursor)
+
+            comment_color = self.highlighter.get_color_name('comment')
+            string_color = self.highlighter.get_color_name('string')
+            if (current_color == comment_color) or (current_color == string_color):
+                return True
+            else:
+                return False
+        else:
+            return False
+
+    def __colon_keyword(self, text):
+        stmt_kws = ['def', 'for', 'if', 'while', 'with', 'class', 'elif',
+                    'except']
+        whole_kws = ['else', 'try', 'except', 'finally']
+        text = text.lstrip()
+        words = text.split()
+        if any([text == wk for wk in whole_kws]):
+            return True
+        elif len(words) < 2:
+            return False
+        elif any([words[0] == sk for sk in stmt_kws]):
+            return True
+        else:
+            return False
+
+    def __forbidden_colon_end_char(self, text):
+        end_chars = [':', '\\', '[', '{', '(', ',']
+        text = text.rstrip()
+        if any([text.endswith(c) for c in end_chars]):
+            return True
+        else:
+            return False
+
+    def __has_colon_not_in_brackets(self, text):
+        """
+        Return whether a string has a colon which is not between brackets.
+        This function returns True if the given string has a colon which is
+        not between a pair of (round, square or curly) brackets. It assumes
+        that the brackets in the string are balanced.
+        """
+        bracket_ext = self.editor_extensions.get(CloseBracketsExtension)
+        for pos, char in enumerate(text):
+            if (char == ':' and
+                    not bracket_ext.unmatched_brackets_in_line(text[:pos])):
+                return True
+        return False
+
+    def __has_unmatched_opening_bracket(self):
+        """
+        Checks if there are any unmatched opening brackets before the current
+        cursor position.
+        """
+        position = self.textCursor().position()
+        for brace in [']', ')', '}']:
+            match = self.find_brace_match(position, brace, forward=False)
+            if match is not None:
+                return True
+        return False
+
+    def autoinsert_colons(self):
+        """Decide if we want to autoinsert colons"""
+        bracket_ext = self.editor_extensions.get(CloseBracketsExtension)
+        self.completion_widget.hide()
+        line_text = self.get_text('sol', 'cursor')
+        if not self.textCursor().atBlockEnd():
+            return False
+        elif self.in_comment_or_string():
+            return False
+        elif not self.__colon_keyword(line_text):
+            return False
+        elif self.__forbidden_colon_end_char(line_text):
+            return False
+        elif bracket_ext.unmatched_brackets_in_line(line_text):
+            return False
+        elif self.__has_colon_not_in_brackets(line_text):
+            return False
+        elif self.__has_unmatched_opening_bracket():
+            return False
+        else:
+            return True
+
+    def next_char(self):
+        cursor = self.textCursor()
+        cursor.movePosition(QTextCursor.NextCharacter,
+                            QTextCursor.KeepAnchor)
+        next_char = to_text_string(cursor.selectedText())
+        return next_char
+
+    def in_comment(self, cursor=None, position=None):
+        """Returns True if the given position is inside a comment.
+
+        Parameters
+        ----------
+        cursor : QTextCursor, optional
+            The position to check.
+        position : int, optional
+            The position to check if *cursor* is None. This parameter
+            is ignored when *cursor* is not None.
+
+        If both *cursor* and *position* are none, then the position returned
+        by self.textCursor() is used instead.
+        """
+        if self.highlighter:
+            if cursor is None:
+                cursor = self.textCursor()
+                if position is not None:
+                    cursor.setPosition(position)
+            current_color = self.__get_current_color(cursor)
+            comment_color = self.highlighter.get_color_name('comment')
+            return (current_color == comment_color)
+        else:
+            return False
+
+    def in_string(self, cursor=None, position=None):
+        """Returns True if the given position is inside a string.
+
+        Parameters
+        ----------
+        cursor : QTextCursor, optional
+            The position to check.
+        position : int, optional
+            The position to check if *cursor* is None. This parameter
+            is ignored when *cursor* is not None.
+
+        If both *cursor* and *position* are none, then the position returned
+        by self.textCursor() is used instead.
+        """
+        if self.highlighter:
+            if cursor is None:
+                cursor = self.textCursor()
+                if position is not None:
+                    cursor.setPosition(position)
+            current_color = self.__get_current_color(cursor)
+            string_color = self.highlighter.get_color_name('string')
+            return (current_color == string_color)
+        else:
+            return False
+
+    # ---- Qt Event handlers
+    # -------------------------------------------------------------------------
+    def setup_context_menu(self):
+        """Setup context menu"""
+        self.undo_action = create_action(
+            self, _("Undo"), icon=ima.icon('undo'),
+            shortcut=self.get_shortcut('undo'), triggered=self.undo)
+        self.redo_action = create_action(
+            self, _("Redo"), icon=ima.icon('redo'),
+            shortcut=self.get_shortcut('redo'), triggered=self.redo)
+        self.cut_action = create_action(
+            self, _("Cut"), icon=ima.icon('editcut'),
+            shortcut=self.get_shortcut('cut'), triggered=self.cut)
+        self.copy_action = create_action(
+            self, _("Copy"), icon=ima.icon('editcopy'),
+            shortcut=self.get_shortcut('copy'), triggered=self.copy)
+        self.paste_action = create_action(
+            self, _("Paste"), icon=ima.icon('editpaste'),
+            shortcut=self.get_shortcut('paste'),
+            triggered=self.paste)
+        selectall_action = create_action(
+            self, _("Select All"), icon=ima.icon('selectall'),
+            shortcut=self.get_shortcut('select all'),
+            triggered=self.selectAll)
+        toggle_comment_action = create_action(
+            self, _("Comment")+"/"+_("Uncomment"), icon=ima.icon('comment'),
+            shortcut=self.get_shortcut('toggle comment'),
+            triggered=self.toggle_comment)
+        self.clear_all_output_action = create_action(
+            self, _("Clear all ouput"), icon=ima.icon('ipython_console'),
+            triggered=self.clear_all_output)
+        self.ipynb_convert_action = create_action(
+            self, _("Convert to Python file"), icon=ima.icon('python'),
+            triggered=self.convert_notebook)
+        self.gotodef_action = create_action(
+            self, _("Go to definition"),
+            shortcut=self.get_shortcut('go to definition'),
+            triggered=self.go_to_definition_from_cursor)
+
+        self.inspect_current_object_action = create_action(
+            self, _("Inspect current object"),
+            icon=ima.icon('MessageBoxInformation'),
+            shortcut=self.get_shortcut('inspect current object'),
+            triggered=self.sig_show_object_info)
+
+        # Run actions
+
+        # Zoom actions
+        zoom_in_action = create_action(
+            self, _("Zoom in"), icon=ima.icon('zoom_in'),
+            shortcut=QKeySequence(QKeySequence.ZoomIn),
+            triggered=self.zoom_in)
+        zoom_out_action = create_action(
+            self, _("Zoom out"), icon=ima.icon('zoom_out'),
+            shortcut=QKeySequence(QKeySequence.ZoomOut),
+            triggered=self.zoom_out)
+        zoom_reset_action = create_action(
+            self, _("Zoom reset"), shortcut=QKeySequence("Ctrl+0"),
+            triggered=self.zoom_reset)
+
+        # Docstring
+        writer = self.writer_docstring
+        self.docstring_action = create_action(
+            self, _("Generate docstring"),
+            shortcut=self.get_shortcut('docstring'),
+            triggered=writer.write_docstring_at_first_line_of_function)
+
+        # Document formatting
+        formatter = self.get_conf(
+            ('provider_configuration', 'lsp', 'values', 'formatting'),
+            default='',
+            section='completions',
+        )
+        self.format_action = create_action(
+            self,
+            _('Format file or selection with {0}').format(
+                formatter.capitalize()),
+            shortcut=self.get_shortcut('autoformatting'),
+            triggered=self.format_document_or_range)
+
+        self.format_action.setEnabled(False)
+
+        # Build menu
+        # TODO: Change to SpyderMenu when the editor is migrated to the new
+        # API
+        self.menu = QMenu(self)
+        actions_1 = [self.gotodef_action, self.inspect_current_object_action,
+                     None, self.undo_action, self.redo_action, None,
+                     self.cut_action, self.copy_action,
+                     self.paste_action, selectall_action]
+        actions_2 = [None, zoom_in_action, zoom_out_action, zoom_reset_action,
+                     None, toggle_comment_action, self.docstring_action,
+                     self.format_action]
+        if nbformat is not None:
+            nb_actions = [self.clear_all_output_action,
+                          self.ipynb_convert_action, None]
+            actions = actions_1 + nb_actions + actions_2
+            add_actions(self.menu, actions)
+        else:
+            actions = actions_1 + actions_2
+            add_actions(self.menu, actions)
+
+        # Read-only context-menu
+        # TODO: Change to SpyderMenu when the editor is migrated to the new
+        # API
+        self.readonly_menu = QMenu(self)
+        add_actions(self.readonly_menu,
+                    (self.copy_action, None, selectall_action,
+                     self.gotodef_action))
+
+    def keyReleaseEvent(self, event):
+        """Override Qt method."""
+        self.sig_key_released.emit(event)
+        key = event.key()
+        direction_keys = {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down}
+        if key in direction_keys:
+            self.request_cursor_event()
+
+        # Update decorations after releasing these keys because they don't
+        # trigger the emission of the valueChanged signal in
+        # verticalScrollBar.
+        # See https://bugreports.qt.io/browse/QTBUG-25365
+        if key in {Qt.Key_Up,  Qt.Key_Down}:
+            self.update_decorations_timer.start()
+
+        # This necessary to run our Pygments highlighter again after the
+        # user generated text changes
+        if event.text():
+            # Stop the active timer and start it again to not run it on
+            # every event
+            if self.timer_syntax_highlight.isActive():
+                self.timer_syntax_highlight.stop()
+
+            # Adjust interval to rehighlight according to the lines
+            # present in the file
+            total_lines = self.get_line_count()
+            if total_lines < 1000:
+                self.timer_syntax_highlight.setInterval(600)
+            elif total_lines < 2000:
+                self.timer_syntax_highlight.setInterval(800)
+            else:
+                self.timer_syntax_highlight.setInterval(1000)
+            self.timer_syntax_highlight.start()
+
+        self._restore_editor_cursor_and_selections()
+        super(CodeEditor, self).keyReleaseEvent(event)
+        event.ignore()
+
+    def event(self, event):
+        """Qt method override."""
+        if event.type() == QEvent.ShortcutOverride:
+            event.ignore()
+            return False
+        else:
+            return super(CodeEditor, self).event(event)
+
+    def _handle_keypress_event(self, event):
+        """Handle keypress events."""
+        TextEditBaseWidget.keyPressEvent(self, event)
+
+        # Trigger the following actions only if the event generates
+        # a text change.
+        text = to_text_string(event.text())
+        if text:
+            # The next three lines are a workaround for a quirk of
+            # QTextEdit on Linux with Qt < 5.15, MacOs and Windows.
+            # See spyder-ide/spyder#12663 and
+            # https://bugreports.qt.io/browse/QTBUG-35861
+            if (
+                parse(QT_VERSION) < parse('5.15')
+                or os.name == 'nt' or sys.platform == 'darwin'
+            ):
+                cursor = self.textCursor()
+                cursor.setPosition(cursor.position())
+                self.setTextCursor(cursor)
+            self.sig_text_was_inserted.emit()
+
+    def keyPressEvent(self, event):
+        """Reimplement Qt method."""
+        if self.completions_hint_after_ms > 0:
+            self._completions_hint_idle = False
+            self._timer_completions_hint.start(self.completions_hint_after_ms)
+        else:
+            self._set_completions_hint_idle()
+
+        # Send the signal to the editor's extension.
+        event.ignore()
+        self.sig_key_pressed.emit(event)
+
+        self._last_pressed_key = key = event.key()
+        self._last_key_pressed_text = text = to_text_string(event.text())
+        has_selection = self.has_selected_text()
+        ctrl = event.modifiers() & Qt.ControlModifier
+        shift = event.modifiers() & Qt.ShiftModifier
+
+        if text:
+            self.clear_occurrences()
+
+
+        if key in {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down}:
+            self.hide_tooltip()
+
+        if event.isAccepted():
+            # The event was handled by one of the editor extension.
+            return
+
+        if key in [Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt,
+                   Qt.Key_Meta, Qt.KeypadModifier]:
+            # The user pressed only a modifier key.
+            if ctrl:
+                pos = self.mapFromGlobal(QCursor.pos())
+                pos = self.calculate_real_position_from_global(pos)
+                if self._handle_goto_uri_event(pos):
+                    event.accept()
+                    return
+
+                if self._handle_goto_definition_event(pos):
+                    event.accept()
+                    return
+            return
+
+        # ---- Handle hard coded and builtin actions
+        operators = {'+', '-', '*', '**', '/', '//', '%', '@', '<<', '>>',
+                     '&', '|', '^', '~', '<', '>', '<=', '>=', '==', '!='}
+        delimiters = {',', ':', ';', '@', '=', '->', '+=', '-=', '*=', '/=',
+                      '//=', '%=', '@=', '&=', '|=', '^=', '>>=', '<<=', '**='}
+
+        if text not in self.auto_completion_characters:
+            if text in operators or text in delimiters:
+                self.completion_widget.hide()
+        if key in (Qt.Key_Enter, Qt.Key_Return):
+            if not shift and not ctrl:
+                if (
+                    self.add_colons_enabled and
+                    self.is_python_like() and
+                    self.autoinsert_colons()
+                ):
+                    self.textCursor().beginEditBlock()
+                    self.insert_text(':' + self.get_line_separator())
+                    if self.strip_trailing_spaces_on_modify:
+                        self.fix_and_strip_indent()
+                    else:
+                        self.fix_indent()
+                    self.textCursor().endEditBlock()
+                elif self.is_completion_widget_visible():
+                    self.select_completion_list()
+                else:
+                    self.textCursor().beginEditBlock()
+                    cur_indent = self.get_block_indentation(
+                        self.textCursor().blockNumber())
+                    self._handle_keypress_event(event)
+                    # Check if we're in a comment or a string at the
+                    # current position
+                    cmt_or_str_cursor = self.in_comment_or_string()
+
+                    # Check if the line start with a comment or string
+                    cursor = self.textCursor()
+                    cursor.setPosition(cursor.block().position(),
+                                       QTextCursor.KeepAnchor)
+                    cmt_or_str_line_begin = self.in_comment_or_string(
+                        cursor=cursor)
+
+                    # Check if we are in a comment or a string
+                    cmt_or_str = cmt_or_str_cursor and cmt_or_str_line_begin
+
+                    if self.strip_trailing_spaces_on_modify:
+                        self.fix_and_strip_indent(
+                            comment_or_string=cmt_or_str,
+                            cur_indent=cur_indent)
+                    else:
+                        self.fix_indent(comment_or_string=cmt_or_str,
+                                        cur_indent=cur_indent)
+                    self.textCursor().endEditBlock()
+        elif key == Qt.Key_Insert and not shift and not ctrl:
+            self.setOverwriteMode(not self.overwriteMode())
+        elif key == Qt.Key_Backspace and not shift and not ctrl:
+            if has_selection or not self.intelligent_backspace:
+                self._handle_keypress_event(event)
+            else:
+                leading_text = self.get_text('sol', 'cursor')
+                leading_length = len(leading_text)
+                trailing_spaces = leading_length - len(leading_text.rstrip())
+                trailing_text = self.get_text('cursor', 'eol')
+                matches = ('()', '[]', '{}', '\'\'', '""')
+                if (
+                    not leading_text.strip() and
+                    (leading_length > len(self.indent_chars))
+                ):
+                    if leading_length % len(self.indent_chars) == 0:
+                        self.unindent()
+                    else:
+                        self._handle_keypress_event(event)
+                elif trailing_spaces and not trailing_text.strip():
+                    self.remove_suffix(leading_text[-trailing_spaces:])
+                elif (
+                    leading_text and
+                    trailing_text and
+                    (leading_text[-1] + trailing_text[0] in matches)
+                ):
+                    cursor = self.textCursor()
+                    cursor.movePosition(QTextCursor.PreviousCharacter)
+                    cursor.movePosition(QTextCursor.NextCharacter,
+                                        QTextCursor.KeepAnchor, 2)
+                    cursor.removeSelectedText()
+                else:
+                    self._handle_keypress_event(event)
+        elif key == Qt.Key_Home:
+            self.stdkey_home(shift, ctrl)
+        elif key == Qt.Key_End:
+            # See spyder-ide/spyder#495: on MacOS X, it is necessary to
+            # redefine this basic action which should have been implemented
+            # natively
+            self.stdkey_end(shift, ctrl)
+        elif (
+            text in self.auto_completion_characters and
+            self.automatic_completions
+        ):
+            self.insert_text(text)
+            if text == ".":
+                if not self.in_comment_or_string():
+                    text = self.get_text('sol', 'cursor')
+                    last_obj = getobj(text)
+                    prev_char = text[-2] if len(text) > 1 else ''
+                    if (
+                        prev_char in {')', ']', '}'} or
+                        (last_obj and not last_obj.isdigit())
+                    ):
+                        # Completions should be triggered immediately when
+                        # an autocompletion character is introduced.
+                        self.do_completion(automatic=True)
+            else:
+                self.do_completion(automatic=True)
+        elif (
+            text in self.signature_completion_characters and
+            not self.has_selected_text()
+        ):
+            self.insert_text(text)
+            self.request_signature()
+        elif (
+            key == Qt.Key_Colon and
+            not has_selection and
+            self.auto_unindent_enabled
+        ):
+            leading_text = self.get_text('sol', 'cursor')
+            if leading_text.lstrip() in ('else', 'finally'):
+                ind = lambda txt: len(txt) - len(txt.lstrip())
+                prevtxt = (to_text_string(self.textCursor().block().
+                           previous().text()))
+                if self.language == 'Python':
+                    prevtxt = prevtxt.rstrip()
+                if ind(leading_text) == ind(prevtxt):
+                    self.unindent(force=True)
+            self._handle_keypress_event(event)
+        elif (
+            key == Qt.Key_Space and
+            not shift and
+            not ctrl and
+            not has_selection and
+            self.auto_unindent_enabled
+        ):
+            self.completion_widget.hide()
+            leading_text = self.get_text('sol', 'cursor')
+            if leading_text.lstrip() in ('elif', 'except'):
+                ind = lambda txt: len(txt)-len(txt.lstrip())
+                prevtxt = (to_text_string(self.textCursor().block().
+                           previous().text()))
+                if self.language == 'Python':
+                    prevtxt = prevtxt.rstrip()
+                if ind(leading_text) == ind(prevtxt):
+                    self.unindent(force=True)
+            self._handle_keypress_event(event)
+        elif key == Qt.Key_Tab and not ctrl:
+            # Important note: <TAB> can't be called with a QShortcut because
+            # of its singular role with respect to widget focus management
+            if not has_selection and not self.tab_mode:
+                self.intelligent_tab()
+            else:
+                # indent the selected text
+                self.indent_or_replace()
+        elif key == Qt.Key_Backtab and not ctrl:
+            # Backtab, i.e. Shift+<TAB>, could be treated as a QShortcut but
+            # there is no point since <TAB> can't (see above)
+            if not has_selection and not self.tab_mode:
+                self.intelligent_backtab()
+            else:
+                # indent the selected text
+                self.unindent()
+            event.accept()
+        elif not event.isAccepted():
+            self._handle_keypress_event(event)
+
+        if not event.modifiers():
+            # Accept event to avoid it being handled by the parent.
+            # Modifiers should be passed to the parent because they
+            # could be shortcuts
+            event.accept()
+
+    def do_automatic_completions(self):
+        """Perform on the fly completions."""
+        if not self.automatic_completions:
+            return
+
+        cursor = self.textCursor()
+        pos = cursor.position()
+        cursor.select(QTextCursor.WordUnderCursor)
+        text = to_text_string(cursor.selectedText())
+
+        key = self._last_pressed_key
+        if key is not None:
+            if key in [Qt.Key_Return, Qt.Key_Escape,
+                       Qt.Key_Tab, Qt.Key_Backtab, Qt.Key_Space]:
+                self._last_pressed_key = None
+                return
+
+        # Correctly handle completions when Backspace key is pressed.
+        # We should not show the widget if deleting a space before a word.
+        if key == Qt.Key_Backspace:
+            cursor.setPosition(max(0, pos - 1), QTextCursor.MoveAnchor)
+            cursor.select(QTextCursor.WordUnderCursor)
+            prev_text = to_text_string(cursor.selectedText())
+            cursor.setPosition(max(0, pos - 1), QTextCursor.MoveAnchor)
+            cursor.setPosition(pos, QTextCursor.KeepAnchor)
+            prev_char = cursor.selectedText()
+            if prev_text == '' or prev_char in (u'\u2029', ' ', '\t'):
+                return
+
+        # Text might be after a dot '.'
+        if text == '':
+            cursor.setPosition(max(0, pos - 1), QTextCursor.MoveAnchor)
+            cursor.select(QTextCursor.WordUnderCursor)
+            text = to_text_string(cursor.selectedText())
+            if text != '.':
+                text = ''
+
+        # WordUnderCursor fails if the cursor is next to a right brace.
+        # If the returned text starts with it, we move to the left.
+        if text.startswith((')', ']', '}')):
+            cursor.setPosition(pos - 1, QTextCursor.MoveAnchor)
+            cursor.select(QTextCursor.WordUnderCursor)
+            text = to_text_string(cursor.selectedText())
+
+        is_backspace = (
+            self.is_completion_widget_visible() and key == Qt.Key_Backspace)
+
+        if (
+            (len(text) >= self.automatic_completions_after_chars) and
+            self._last_key_pressed_text or
+            is_backspace
+        ):
+            # Perform completion on the fly
+            if not self.in_comment_or_string():
+                # Variables can include numbers and underscores
+                if (
+                    text.isalpha() or
+                    text.isalnum() or
+                    '_' in text
+                    or '.' in text
+                ):
+                    self.do_completion(automatic=True)
+                    self._last_key_pressed_text = ''
+                    self._last_pressed_key = None
+
+    def fix_and_strip_indent(self, *args, **kwargs):
+        """
+        Automatically fix indent and strip previous automatic indent.
+
+        args and kwargs are forwarded to self.fix_indent
+        """
+        # Fix indent
+        cursor_before = self.textCursor().position()
+        # A change just occurred on the last line (return was pressed)
+        if cursor_before > 0:
+            self.last_change_position = cursor_before - 1
+        self.fix_indent(*args, **kwargs)
+        cursor_after = self.textCursor().position()
+        # Remove previous spaces and update last_auto_indent
+        nspaces_removed = self.strip_trailing_spaces()
+        self.last_auto_indent = (cursor_before - nspaces_removed,
+                                 cursor_after - nspaces_removed)
+
+    def run_pygments_highlighter(self):
+        """Run pygments highlighter."""
+        if isinstance(self.highlighter, sh.PygmentsSH):
+            self.highlighter.make_charlist()
+
+    def get_pattern_at(self, coordinates):
+        """
+        Return key, text and cursor for pattern (if found at coordinates).
+        """
+        return self.get_pattern_cursor_at(self.highlighter.patterns,
+                                          coordinates)
+
+    def get_pattern_cursor_at(self, pattern, coordinates):
+        """
+        Find pattern located at the line where the coordinate is located.
+
+        This returns the actual match and the cursor that selects the text.
+        """
+        cursor, key, text = None, None, None
+        break_loop = False
+
+        # Check if the pattern is in line
+        line = self.get_line_at(coordinates)
+
+        for match in pattern.finditer(line):
+            for key, value in list(match.groupdict().items()):
+                if value:
+                    start, end = sh.get_span(match)
+
+                    # Get cursor selection if pattern found
+                    cursor = self.cursorForPosition(coordinates)
+                    cursor.movePosition(QTextCursor.StartOfBlock)
+                    line_start_position = cursor.position()
+
+                    cursor.setPosition(line_start_position + start,
+                                       cursor.MoveAnchor)
+                    start_rect = self.cursorRect(cursor)
+                    cursor.setPosition(line_start_position + end,
+                                       cursor.MoveAnchor)
+                    end_rect = self.cursorRect(cursor)
+                    bounding_rect = start_rect.united(end_rect)
+
+                    # Check coordinates are located within the selection rect
+                    if bounding_rect.contains(coordinates):
+                        text = line[start:end]
+                        cursor.setPosition(line_start_position + start,
+                                           cursor.KeepAnchor)
+                        break_loop = True
+                        break
+
+            if break_loop:
+                break
+
+        return key, text, cursor
+
+    def _preprocess_file_uri(self, uri):
+        """Format uri to conform to absolute or relative file paths."""
+        fname = uri.replace('file://', '')
+        if fname[-1] == '/':
+            fname = fname[:-1]
+
+        # ^/ is used to denote the current project root
+        if fname.startswith("^/"):
+            if self.current_project_path is not None:
+                fname = osp.join(self.current_project_path, fname[2:])
+            else:
+                fname = fname.replace("^/", "~/")
+
+        if fname.startswith("~/"):
+            fname = osp.expanduser(fname)
+
+        dirname = osp.dirname(osp.abspath(self.filename))
+        if osp.isdir(dirname):
+            if not osp.isfile(fname):
+                # Maybe relative
+                fname = osp.join(dirname, fname)
+
+        self.sig_file_uri_preprocessed.emit(fname)
+
+        return fname
+
+    def _handle_goto_definition_event(self, pos):
+        """Check if goto definition can be applied and apply highlight."""
+        text = self.get_word_at(pos)
+        if text and not sourcecode.is_keyword(to_text_string(text)):
+            if not self.__cursor_changed:
+                QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor))
+                self.__cursor_changed = True
+            cursor = self.cursorForPosition(pos)
+            cursor.select(QTextCursor.WordUnderCursor)
+            self.clear_extra_selections('ctrl_click')
+            self.highlight_selection(
+                'ctrl_click', cursor,
+                foreground_color=self.ctrl_click_color,
+                underline_color=self.ctrl_click_color,
+                underline_style=QTextCharFormat.SingleUnderline)
+            return True
+        else:
+            return False
+
+    def _handle_goto_uri_event(self, pos):
+        """Check if go to uri can be applied and apply highlight."""
+        key, pattern_text, cursor = self.get_pattern_at(pos)
+        if key and pattern_text and cursor:
+            self._last_hover_pattern_key = key
+            self._last_hover_pattern_text = pattern_text
+
+            color = self.ctrl_click_color
+
+            if key in ['file']:
+                fname = self._preprocess_file_uri(pattern_text)
+                if not osp.isfile(fname):
+                    color = QColor(SpyderPalette.COLOR_ERROR_2)
+
+            self.clear_extra_selections('ctrl_click')
+            self.highlight_selection(
+                'ctrl_click', cursor,
+                foreground_color=color,
+                underline_color=color,
+                underline_style=QTextCharFormat.SingleUnderline)
+
+            if not self.__cursor_changed:
+                QApplication.setOverrideCursor(
+                    QCursor(Qt.PointingHandCursor))
+                self.__cursor_changed = True
+
+            self.sig_uri_found.emit(pattern_text)
+            return True
+        else:
+            self._last_hover_pattern_key = key
+            self._last_hover_pattern_text = pattern_text
+            return False
+
+    def go_to_uri_from_cursor(self, uri):
+        """Go to url from cursor and defined hover patterns."""
+        key = self._last_hover_pattern_key
+        full_uri = uri
+
+        if key in ['file']:
+            fname = self._preprocess_file_uri(uri)
+
+            if osp.isfile(fname) and encoding.is_text_file(fname):
+                # Open in editor
+                self.go_to_definition.emit(fname, 0, 0)
+            else:
+                # Use external program
+                fname = file_uri(fname)
+                start_file(fname)
+        elif key in ['mail', 'url']:
+            if '@' in uri and not uri.startswith('mailto:'):
+                full_uri = 'mailto:' + uri
+            quri = QUrl(full_uri)
+            QDesktopServices.openUrl(quri)
+        elif key in ['issue']:
+            # Issue URI
+            repo_url = uri.replace('#', '/issues/')
+            if uri.startswith(('gh-', 'bb-', 'gl-')):
+                number = uri[3:]
+                remotes = get_git_remotes(self.filename)
+                remote = remotes.get('upstream', remotes.get('origin'))
+                if remote:
+                    full_uri = remote_to_url(remote) + '/issues/' + number
+                else:
+                    full_uri = None
+            elif uri.startswith('gh:') or ':' not in uri:
+                # Github
+                repo_and_issue = repo_url
+                if uri.startswith('gh:'):
+                    repo_and_issue = repo_url[3:]
+                full_uri = 'https://github.com/' + repo_and_issue
+            elif uri.startswith('gl:'):
+                # Gitlab
+                full_uri = 'https://gitlab.com/' + repo_url[3:]
+            elif uri.startswith('bb:'):
+                # Bitbucket
+                full_uri = 'https://bitbucket.org/' + repo_url[3:]
+
+            if full_uri:
+                quri = QUrl(full_uri)
+                QDesktopServices.openUrl(quri)
+            else:
+                QMessageBox.information(
+                    self,
+                    _('Information'),
+                    _('This file is not part of a local repository or '
+                      'upstream/origin remotes are not defined!'),
+                    QMessageBox.Ok,
+                )
+        self.hide_tooltip()
+        return full_uri
+
+    def line_range(self, position):
+        """
+        Get line range from position.
+        """
+        if position is None:
+            return None
+        if position >= self.document().characterCount():
+            return None
+        # Check if still on the line
+        cursor = self.textCursor()
+        cursor.setPosition(position)
+        line_range = (cursor.block().position(),
+                      cursor.block().position()
+                      + cursor.block().length() - 1)
+        return line_range
+
+    def strip_trailing_spaces(self):
+        """
+        Strip trailing spaces if needed.
+
+        Remove trailing whitespace on leaving a non-string line containing it.
+        Return the number of removed spaces.
+        """
+        if not running_under_pytest():
+            if not self.hasFocus():
+                # Avoid problem when using split editor
+                return 0
+        # Update current position
+        current_position = self.textCursor().position()
+        last_position = self.last_position
+        self.last_position = current_position
+
+        if self.skip_rstrip:
+            return 0
+
+        line_range = self.line_range(last_position)
+        if line_range is None:
+            # Doesn't apply
+            return 0
+
+        def pos_in_line(pos):
+            """Check if pos is in last line."""
+            if pos is None:
+                return False
+            return line_range[0] <= pos <= line_range[1]
+
+        if pos_in_line(current_position):
+            # Check if still on the line
+            return 0
+
+        # Check if end of line in string
+        cursor = self.textCursor()
+        cursor.setPosition(line_range[1])
+
+        if (not self.strip_trailing_spaces_on_modify
+                or self.in_string(cursor=cursor)):
+            if self.last_auto_indent is None:
+                return 0
+            elif (self.last_auto_indent !=
+                  self.line_range(self.last_auto_indent[0])):
+                # line not empty
+                self.last_auto_indent = None
+                return 0
+            line_range = self.last_auto_indent
+            self.last_auto_indent = None
+        elif not pos_in_line(self.last_change_position):
+            # Should process if pressed return or made a change on the line:
+            return 0
+
+        cursor.setPosition(line_range[0])
+        cursor.setPosition(line_range[1],
+                           QTextCursor.KeepAnchor)
+        # remove spaces on the right
+        text = cursor.selectedText()
+        strip = text.rstrip()
+        # I think all the characters we can strip are in a single QChar.
+        # Therefore there shouldn't be any length problems.
+        N_strip = qstring_length(text[len(strip):])
+
+        if N_strip > 0:
+            # Select text to remove
+            cursor.setPosition(line_range[1] - N_strip)
+            cursor.setPosition(line_range[1],
+                               QTextCursor.KeepAnchor)
+            cursor.removeSelectedText()
+            # Correct last change position
+            self.last_change_position = line_range[1]
+            self.last_position = self.textCursor().position()
+            return N_strip
+        return 0
+
+    def move_line_up(self):
+        """Move up current line or selected text"""
+        self.__move_line_or_selection(after_current_line=False)
+
+    def move_line_down(self):
+        """Move down current line or selected text"""
+        self.__move_line_or_selection(after_current_line=True)
+
+    def __move_line_or_selection(self, after_current_line=True):
+        cursor = self.textCursor()
+        # Unfold any folded code block before moving lines up/down
+        folding_panel = self.panels.get('FoldingPanel')
+        fold_start_line = cursor.blockNumber() + 1
+        block = cursor.block().next()
+
+        if fold_start_line in folding_panel.folding_status:
+            fold_status = folding_panel.folding_status[fold_start_line]
+            if fold_status:
+                folding_panel.toggle_fold_trigger(block)
+
+        if after_current_line:
+            # Unfold any folded region when moving lines down
+            fold_start_line = cursor.blockNumber() + 2
+            block = cursor.block().next().next()
+
+            if fold_start_line in folding_panel.folding_status:
+                fold_status = folding_panel.folding_status[fold_start_line]
+                if fold_status:
+                    folding_panel.toggle_fold_trigger(block)
+        else:
+            # Unfold any folded region when moving lines up
+            block = cursor.block()
+            offset = 0
+            if self.has_selected_text():
+                ((selection_start, _),
+                 (selection_end)) = self.get_selection_start_end()
+                if selection_end != selection_start:
+                    offset = 1
+            fold_start_line = block.blockNumber() - 1 - offset
+
+            # Find the innermost code folding region for the current position
+            enclosing_regions = sorted(list(
+                folding_panel.current_tree[fold_start_line]))
+
+            folding_status = folding_panel.folding_status
+            if len(enclosing_regions) > 0:
+                for region in enclosing_regions:
+                    fold_start_line = region.begin
+                    block = self.document().findBlockByNumber(fold_start_line)
+                    if fold_start_line in folding_status:
+                        fold_status = folding_status[fold_start_line]
+                        if fold_status:
+                            folding_panel.toggle_fold_trigger(block)
+
+        self._TextEditBaseWidget__move_line_or_selection(
+            after_current_line=after_current_line)
+
+    def mouseMoveEvent(self, event):
+        """Underline words when pressing <CONTROL>"""
+        # Restart timer every time the mouse is moved
+        # This is needed to correctly handle hover hints with a delay
+        self._timer_mouse_moving.start()
+
+        pos = event.pos()
+        self._last_point = pos
+        alt = event.modifiers() & Qt.AltModifier
+        ctrl = event.modifiers() & Qt.ControlModifier
+
+        if alt:
+            self.sig_alt_mouse_moved.emit(event)
+            event.accept()
+            return
+
+        if ctrl:
+            if self._handle_goto_uri_event(pos):
+                event.accept()
+                return
+
+        if self.has_selected_text():
+            TextEditBaseWidget.mouseMoveEvent(self, event)
+            return
+
+        if self.go_to_definition_enabled and ctrl:
+            if self._handle_goto_definition_event(pos):
+                event.accept()
+                return
+
+        if self.__cursor_changed:
+            self._restore_editor_cursor_and_selections()
+        else:
+            if (not self._should_display_hover(pos)
+                    and not self.is_completion_widget_visible()):
+                self.hide_tooltip()
+
+        TextEditBaseWidget.mouseMoveEvent(self, event)
+
+    def setPlainText(self, txt):
+        """
+        Extends setPlainText to emit the new_text_set signal.
+
+        :param txt: The new text to set.
+        :param mime_type: Associated mimetype. Setting the mime will update the
+                          pygments lexer.
+        :param encoding: text encoding
+        """
+        super(CodeEditor, self).setPlainText(txt)
+        self.new_text_set.emit()
+
+    def focusOutEvent(self, event):
+        """Extend Qt method"""
+        self.sig_focus_changed.emit()
+        self._restore_editor_cursor_and_selections()
+        super(CodeEditor, self).focusOutEvent(event)
+
+    def focusInEvent(self, event):
+        formatting_enabled = getattr(self, 'formatting_enabled', False)
+        self.sig_refresh_formatting.emit(formatting_enabled)
+        super(CodeEditor, self).focusInEvent(event)
+
+    def leaveEvent(self, event):
+        """Extend Qt method"""
+        self.sig_leave_out.emit()
+        self._restore_editor_cursor_and_selections()
+        TextEditBaseWidget.leaveEvent(self, event)
+
+    def mousePressEvent(self, event):
+        """Override Qt method."""
+        self.hide_tooltip()
+
+        ctrl = event.modifiers() & Qt.ControlModifier
+        alt = event.modifiers() & Qt.AltModifier
+        pos = event.pos()
+        self._mouse_left_button_pressed = event.button() == Qt.LeftButton
+
+        if event.button() == Qt.LeftButton and ctrl:
+            TextEditBaseWidget.mousePressEvent(self, event)
+            cursor = self.cursorForPosition(pos)
+            uri = self._last_hover_pattern_text
+            if uri:
+                self.go_to_uri_from_cursor(uri)
+            else:
+                self.go_to_definition_from_cursor(cursor)
+        elif event.button() == Qt.LeftButton and alt:
+            self.sig_alt_left_mouse_pressed.emit(event)
+        else:
+            TextEditBaseWidget.mousePressEvent(self, event)
+
+    def mouseReleaseEvent(self, event):
+        """Override Qt method."""
+        if event.button() == Qt.LeftButton:
+            self._mouse_left_button_pressed = False
+
+        self.request_cursor_event()
+        TextEditBaseWidget.mouseReleaseEvent(self, event)
+
+    def contextMenuEvent(self, event):
+        """Reimplement Qt method"""
+        nonempty_selection = self.has_selected_text()
+        self.copy_action.setEnabled(nonempty_selection)
+        self.cut_action.setEnabled(nonempty_selection)
+        self.clear_all_output_action.setVisible(self.is_json() and
+                                                nbformat is not None)
+        self.ipynb_convert_action.setVisible(self.is_json() and
+                                             nbformat is not None)
+        self.gotodef_action.setVisible(self.go_to_definition_enabled)
+
+        formatter = self.get_conf(
+            ('provider_configuration', 'lsp', 'values', 'formatting'),
+            default='',
+            section='completions'
+        )
+        self.format_action.setText(_(
+            'Format file or selection with {0}').format(
+                formatter.capitalize()))
+
+        # Check if a docstring is writable
+        writer = self.writer_docstring
+        writer.line_number_cursor = self.get_line_number_at(event.pos())
+        result = writer.get_function_definition_from_first_line()
+
+        if result:
+            self.docstring_action.setEnabled(True)
+        else:
+            self.docstring_action.setEnabled(False)
+
+        # Code duplication go_to_definition_from_cursor and mouse_move_event
+        cursor = self.textCursor()
+        text = to_text_string(cursor.selectedText())
+        if len(text) == 0:
+            cursor.select(QTextCursor.WordUnderCursor)
+            text = to_text_string(cursor.selectedText())
+
+        self.undo_action.setEnabled(self.document().isUndoAvailable())
+        self.redo_action.setEnabled(self.document().isRedoAvailable())
+        menu = self.menu
+        if self.isReadOnly():
+            menu = self.readonly_menu
+        menu.popup(event.globalPos())
+        event.accept()
+
+    def _restore_editor_cursor_and_selections(self):
+        """Restore the cursor and extra selections of this code editor."""
+        if self.__cursor_changed:
+            self.__cursor_changed = False
+            QApplication.restoreOverrideCursor()
+            self.clear_extra_selections('ctrl_click')
+            self._last_hover_pattern_key = None
+            self._last_hover_pattern_text = None
+
+    # ---- Drag and drop
+    # -------------------------------------------------------------------------
+    def dragEnterEvent(self, event):
+        """
+        Reimplemented Qt method.
+
+        Inform Qt about the types of data that the widget accepts.
+        """
+        logger.debug("dragEnterEvent was received")
+        all_urls = mimedata2url(event.mimeData())
+        if all_urls:
+            # Let the parent widget handle this
+            logger.debug("Let the parent widget handle this dragEnterEvent")
+            event.ignore()
+        else:
+            logger.debug("Call TextEditBaseWidget dragEnterEvent method")
+            TextEditBaseWidget.dragEnterEvent(self, event)
+
+    def dropEvent(self, event):
+        """
+        Reimplemented Qt method.
+
+        Unpack dropped data and handle it.
+        """
+        logger.debug("dropEvent was received")
+        if mimedata2url(event.mimeData()):
+            logger.debug("Let the parent widget handle this")
+            event.ignore()
+        else:
+            logger.debug("Call TextEditBaseWidget dropEvent method")
+            TextEditBaseWidget.dropEvent(self, event)
+
+    # ---- Paint event
+    # -------------------------------------------------------------------------
+    def paintEvent(self, event):
+        """Overrides paint event to update the list of visible blocks"""
+        self.update_visible_blocks(event)
+        TextEditBaseWidget.paintEvent(self, event)
+        self.painted.emit(event)
+
+    def update_visible_blocks(self, event):
+        """Update the list of visible blocks/lines position"""
+        self.__visible_blocks[:] = []
+        block = self.firstVisibleBlock()
+        blockNumber = block.blockNumber()
+        top = int(self.blockBoundingGeometry(block).translated(
+            self.contentOffset()).top())
+        bottom = top + int(self.blockBoundingRect(block).height())
+        ebottom_bottom = self.height()
+
+        while block.isValid():
+            visible = bottom <= ebottom_bottom
+            if not visible:
+                break
+            if block.isVisible():
+                self.__visible_blocks.append((top, blockNumber+1, block))
+            block = block.next()
+            top = bottom
+            bottom = top + int(self.blockBoundingRect(block).height())
+            blockNumber = block.blockNumber()
+
+    def _draw_editor_cell_divider(self):
+        """Draw a line on top of a define cell"""
+        if self.supported_cell_language:
+            cell_line_color = self.comment_color
+            painter = QPainter(self.viewport())
+            pen = painter.pen()
+            pen.setStyle(Qt.SolidLine)
+            pen.setBrush(cell_line_color)
+            painter.setPen(pen)
+
+            for top, line_number, block in self.visible_blocks:
+                if is_cell_header(block):
+                    painter.drawLine(0, top, self.width(), top)
+
+    @property
+    def visible_blocks(self):
+        """
+        Returns the list of visible blocks.
+
+        Each element in the list is a tuple made up of the line top position,
+        the line number (already 1 based), and the QTextBlock itself.
+
+        :return: A list of tuple(top position, line number, block)
+        :rtype: List of tuple(int, int, QtGui.QTextBlock)
+        """
+        return self.__visible_blocks
+
+    def is_editor(self):
+        return True
+
+    def popup_docstring(self, prev_text, prev_pos):
+        """Show the menu for generating docstring."""
+        line_text = self.textCursor().block().text()
+        if line_text != prev_text:
+            return
+
+        if prev_pos != self.textCursor().position():
+            return
+
+        writer = self.writer_docstring
+        if writer.get_function_definition_from_below_last_line():
+            point = self.cursorRect().bottomRight()
+            point = self.calculate_real_position(point)
+            point = self.mapToGlobal(point)
+
+            self.menu_docstring = QMenuOnlyForEnter(self)
+            self.docstring_action = create_action(
+                self, _("Generate docstring"), icon=ima.icon('TextFileIcon'),
+                triggered=writer.write_docstring)
+            self.menu_docstring.addAction(self.docstring_action)
+            self.menu_docstring.setActiveAction(self.docstring_action)
+            self.menu_docstring.popup(point)
+
+    def delayed_popup_docstring(self):
+        """Show context menu for docstring.
+
+        This method is called after typing '''. After typing ''', this function
+        waits 300ms. If there was no input for 300ms, show the context menu.
+        """
+        line_text = self.textCursor().block().text()
+        pos = self.textCursor().position()
+
+        timer = QTimer()
+        timer.singleShot(300, lambda: self.popup_docstring(line_text, pos))
+
+    def set_current_project_path(self, root_path=None):
+        """
+        Set the current active project root path.
+
+        Parameters
+        ----------
+        root_path: str or None, optional
+            Path to current project root path. Default is None.
+        """
+        self.current_project_path = root_path
+
+    def count_leading_empty_lines(self, cell):
+        """Count the number of leading empty cells."""
+        lines = cell.splitlines(keepends=True)
+        if not lines:
+            return 0
+        for i, line in enumerate(lines):
+            if line and not line.isspace():
+                return i
+        return len(lines)
+
+    def ipython_to_python(self, code):
+        """Transform IPython code to python code."""
+        tm = TransformerManager()
+        number_empty_lines = self.count_leading_empty_lines(code)
+        try:
+            code = tm.transform_cell(code)
+        except SyntaxError:
+            return code
+        return '\n' * number_empty_lines + code
+
+    def is_letter_or_number(self, char):
+        """
+        Returns whether the specified unicode character is a letter or a
+        number.
+        """
+        cat = category(char)
+        return cat.startswith('L') or cat.startswith('N')
+
+
+# =============================================================================
+# Editor + Class browser test
+# =============================================================================
+class TestWidget(QSplitter):
+    def __init__(self, parent):
+        QSplitter.__init__(self, parent)
+        self.editor = CodeEditor(self)
+        self.editor.setup_editor(linenumbers=True, markers=True, tab_mode=False,
+                                 font=QFont("Courier New", 10),
+                                 show_blanks=True, color_scheme='Zenburn')
+        self.addWidget(self.editor)
+        self.setWindowIcon(ima.icon('spyder'))
+
+    def load(self, filename):
+        self.editor.set_text_from_file(filename)
+        self.setWindowTitle("%s - %s (%s)" % (_("Editor"),
+                                              osp.basename(filename),
+                                              osp.dirname(filename)))
+        self.editor.hide_tooltip()
+
+
+def test(fname):
+    from spyder.utils.qthelpers import qapplication
+    app = qapplication(test_time=5)
+    win = TestWidget(None)
+    win.show()
+    win.load(fname)
+    win.resize(900, 700)
+    sys.exit(app.exec_())
+
+
+if __name__ == '__main__':
+    if len(sys.argv) > 1:
+        fname = sys.argv[1]
+    else:
+        fname = __file__
+    test(fname)
diff --git a/tests/corpus/update.file.delete.function/1.py b/tests/corpus/update.file.delete.function/1.py
new file mode 100644
index 0000000..6b3e17f
--- /dev/null
+++ b/tests/corpus/update.file.delete.function/1.py
@@ -0,0 +1,2 @@
+def i_exist():
+    pass
diff --git a/tests/corpus/update.file.delete.function/2.py b/tests/corpus/update.file.delete.function/2.py
new file mode 100644
index 0000000..2fd5d2a
--- /dev/null
+++ b/tests/corpus/update.file.delete.function/2.py
@@ -0,0 +1,6 @@
+def something():
+    pass
+def i_exist():
+    pass
+def something():
+    pass
diff --git a/tests/corpus/update.file.delete.function/chat.xml b/tests/corpus/update.file.delete.function/chat.xml
new file mode 100644
index 0000000..dce60de
--- /dev/null
+++ b/tests/corpus/update.file.delete.function/chat.xml
@@ -0,0 +1,10 @@
+<no-train>
+```CEDARScript
+UPDATE FILE "2.py" DELETE FUNCTION "i_exist";
+```
+    <throws value="Unable to find function 'does-not-exist'">
+```CEDARScript
+UPDATE FILE "1.py" DELETE FUNCTION "does-not-exist";
+```
+    </throws>
+</no-train>
diff --git a/tests/corpus/update.file.delete.function/expected.1.py b/tests/corpus/update.file.delete.function/expected.1.py
new file mode 100644
index 0000000..6b3e17f
--- /dev/null
+++ b/tests/corpus/update.file.delete.function/expected.1.py
@@ -0,0 +1,2 @@
+def i_exist():
+    pass
diff --git a/tests/corpus/update.file.delete.function/expected.2.py b/tests/corpus/update.file.delete.function/expected.2.py
new file mode 100644
index 0000000..ce7bad4
--- /dev/null
+++ b/tests/corpus/update.file.delete.function/expected.2.py
@@ -0,0 +1,4 @@
+def something():
+    pass
+def something():
+    pass
diff --git a/tests/corpus/update.file.delete.line/chat.xml b/tests/corpus/update.file.delete.line/chat.xml
new file mode 100644
index 0000000..a74487c
--- /dev/null
+++ b/tests/corpus/update.file.delete.line/chat.xml
@@ -0,0 +1,6 @@
+<no-train>
+```CEDARScript
+UPDATE FILE "main.something" DELETE LINE 'risus cursus'
+UPDATE FILE "main.something" DELETE LINE "etiam a"
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/update.file.delete.line/expected.main.something b/tests/corpus/update.file.delete.line/expected.main.something
new file mode 100644
index 0000000..671a4e4
--- /dev/null
+++ b/tests/corpus/update.file.delete.line/expected.main.something
@@ -0,0 +1,13 @@
+Lorem ipsum odor amet,
+  consectetuer adipiscing elit.
+Ligula vestibulum semper sagittis sapien class.
+Dolor nascetur litora feugiat urna,
+    natoque venenatis fames.
+    At elementum urna suspendisse
+    himenaeos massa dui.
+        Vivamus in
+            ac vulputate dolor;
+                amet pulvinar.
+                    Fames tempus habitasse
+                      parturient
+                    mollis.
diff --git a/tests/corpus/update.file.delete.line/main.something b/tests/corpus/update.file.delete.line/main.something
new file mode 100644
index 0000000..3d71d47
--- /dev/null
+++ b/tests/corpus/update.file.delete.line/main.something
@@ -0,0 +1,15 @@
+Lorem ipsum odor amet,
+  consectetuer adipiscing elit.
+Ligula vestibulum semper sagittis sapien class.
+Dolor nascetur litora feugiat urna,
+    natoque venenatis fames.
+    At elementum urna suspendisse
+    himenaeos massa dui.
+        Vivamus in
+          risus cursus
+            ac vulputate dolor;
+                amet pulvinar.
+                    Fames tempus habitasse
+                      etiam a
+                      parturient
+                    mollis.
diff --git a/tests/corpus/update.file.insert.before.line/chat.xml b/tests/corpus/update.file.insert.before.line/chat.xml
new file mode 100644
index 0000000..5ea262a
--- /dev/null
+++ b/tests/corpus/update.file.insert.before.line/chat.xml
@@ -0,0 +1,9 @@
+<no-train>
+```CEDARScript
+UPDATE FILE "main.something"
+INSERT BEFORE LINE "parturient"
+WITH CONTENT '''
+@-4:#@-4 before line 'parturient'
+'''
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/update.file.insert.before.line/expected.main.something b/tests/corpus/update.file.insert.before.line/expected.main.something
new file mode 100644
index 0000000..fa5ffa8
--- /dev/null
+++ b/tests/corpus/update.file.insert.before.line/expected.main.something
@@ -0,0 +1,16 @@
+Lorem ipsum odor amet,
+  consectetuer adipiscing elit.
+Ligula vestibulum semper sagittis sapien class.
+Dolor nascetur litora feugiat urna,
+    natoque venenatis fames.
+    At elementum urna suspendisse
+    himenaeos massa dui.
+        Vivamus in
+          risus cursus
+            ac vulputate dolor;
+                amet pulvinar.
+                    Fames tempus habitasse
+                      etiam a
+              #@-4 before line 'parturient'
+                      parturient
+                    mollis.
diff --git a/tests/corpus/update.file.insert.before.line/main.something b/tests/corpus/update.file.insert.before.line/main.something
new file mode 100644
index 0000000..3d71d47
--- /dev/null
+++ b/tests/corpus/update.file.insert.before.line/main.something
@@ -0,0 +1,15 @@
+Lorem ipsum odor amet,
+  consectetuer adipiscing elit.
+Ligula vestibulum semper sagittis sapien class.
+Dolor nascetur litora feugiat urna,
+    natoque venenatis fames.
+    At elementum urna suspendisse
+    himenaeos massa dui.
+        Vivamus in
+          risus cursus
+            ac vulputate dolor;
+                amet pulvinar.
+                    Fames tempus habitasse
+                      etiam a
+                      parturient
+                    mollis.
diff --git a/tests/corpus/update.file.replace.segment/1.py b/tests/corpus/update.file.replace.segment/1.py
new file mode 100644
index 0000000..91fe19e
--- /dev/null
+++ b/tests/corpus/update.file.replace.segment/1.py
@@ -0,0 +1,22 @@
+class A:
+    def __init__(self, value):
+        self._value = value
+    def m1(self):
+        pass
+
+
+class B:
+    def m0(self):
+        pass
+    def __init__(self, b):
+        self.b = b
+    def m1(self):
+        pass #
+    def m2(self):
+        pass
+
+class C:
+    def __init__(self):
+        pass
+    def m1(self):
+        pass
diff --git a/tests/corpus/update.file.replace.segment/chat.xml b/tests/corpus/update.file.replace.segment/chat.xml
new file mode 100644
index 0000000..6f80ba5
--- /dev/null
+++ b/tests/corpus/update.file.replace.segment/chat.xml
@@ -0,0 +1,12 @@
+<no-train>
+```CEDARScript
+UPDATE FILE "1.py"
+REPLACE SEGMENT
+STARTING AFTER LINE "self.b = b"
+ENDING AT LINE "pass #"
+WITH CONTENT '''
+@-1:def ok(self):
+@0:pass # OK
+''';
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/update.file.replace.segment/expected.1.py b/tests/corpus/update.file.replace.segment/expected.1.py
new file mode 100644
index 0000000..00566fe
--- /dev/null
+++ b/tests/corpus/update.file.replace.segment/expected.1.py
@@ -0,0 +1,22 @@
+class A:
+    def __init__(self, value):
+        self._value = value
+    def m1(self):
+        pass
+
+
+class B:
+    def m0(self):
+        pass
+    def __init__(self, b):
+        self.b = b
+    def ok(self):
+        pass # OK
+    def m2(self):
+        pass
+
+class C:
+    def __init__(self):
+        pass
+    def m1(self):
+        pass
diff --git a/tests/corpus/update.file.replace.whole/1.py b/tests/corpus/update.file.replace.whole/1.py
new file mode 100644
index 0000000..79a7eda
--- /dev/null
+++ b/tests/corpus/update.file.replace.whole/1.py
@@ -0,0 +1,2 @@
+class A:
+    pass
diff --git a/tests/corpus/update.file.replace.whole/chat.xml b/tests/corpus/update.file.replace.whole/chat.xml
new file mode 100644
index 0000000..3c11618
--- /dev/null
+++ b/tests/corpus/update.file.replace.whole/chat.xml
@@ -0,0 +1,10 @@
+<no-train>
+```CEDARScript
+UPDATE FILE "1.py"
+REPLACE WHOLE
+WITH CONTENT '''
+@0:class B:
+@1:...
+'''
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/update.file.replace.whole/expected.1.py b/tests/corpus/update.file.replace.whole/expected.1.py
new file mode 100644
index 0000000..5de355e
--- /dev/null
+++ b/tests/corpus/update.file.replace.whole/expected.1.py
@@ -0,0 +1,2 @@
+class B:
+    ...
diff --git a/tests/corpus/update.identifier.case-filter/1.kts b/tests/corpus/update.identifier.case-filter/1.kts
new file mode 100644
index 0000000..8b243e9
--- /dev/null
+++ b/tests/corpus/update.identifier.case-filter/1.kts
@@ -0,0 +1,15 @@
+application {
+    this.mainClassName = mainVerticleName
+}
+
+
+named<JavaExec>("run") {
+    doFirst {
+        args = listOf(
+            "run",
+            mainVerticleName,
+            "--launcher-class=${application.mainClassName}",
+            "--on-redeploy=$doOnChange"
+        )
+    }
+}
diff --git a/tests/corpus/update.identifier.case-filter/1.py b/tests/corpus/update.identifier.case-filter/1.py
new file mode 100644
index 0000000..acbeaaf
--- /dev/null
+++ b/tests/corpus/update.identifier.case-filter/1.py
@@ -0,0 +1,9 @@
+class BaseConverter:
+    def __init(self, s):
+        def encode(self, i):
+            neg, value = (self.convert(i, self.decimal_digits, self.digits, "-"))
+    def decode(self, s):
+        neg, value = self.convert(s, self.digits, self.decimal_digits, self.sign)
+def xx(self, number, from_digits, to_digits, sign):
+    def convert(self, number, from_digits, to_digits, sign):
+def convert(self, number, from_digits, to_digits, sign):
diff --git a/tests/corpus/update.identifier.case-filter/1.txt b/tests/corpus/update.identifier.case-filter/1.txt
new file mode 100644
index 0000000..5c969fe
--- /dev/null
+++ b/tests/corpus/update.identifier.case-filter/1.txt
@@ -0,0 +1,10 @@
+class BaseConverter:
+    def __init(self, s):
+        def encode(self, i):
+            neg, value = (self.convert(i, self.decimal_digits, self.digits, "-"))
+    def decode(self, s):
+        neg, value = self.convert(s, self.digits, self.decimal_digits, self.sign)
+def convert(self, number, from_digits, to_digits, sign):
+    b = def convert(self, number, from_digits, to_digits, sign):
+        def a():
+def convert(self, number, from_digits, to_digits, sign):
diff --git a/tests/corpus/update.identifier.case-filter/analyzer_cli.py b/tests/corpus/update.identifier.case-filter/analyzer_cli.py
new file mode 100644
index 0000000..5474716
--- /dev/null
+++ b/tests/corpus/update.identifier.case-filter/analyzer_cli.py
@@ -0,0 +1,35 @@
+# Copyright 2016 The TensorFlow Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ==============================================================================
+"""CLI Backend for the Analyzer Part of the Debugger.
+
+The analyzer performs post hoc analysis of dumped intermediate tensors and
+graph structure information from debugged Session.run() calls.
+"""
+
+def _make_source_table(self, source_list, is_tf_py_library):
+    lines=[]
+    return debugger_cli_common.rich_text_lines_from_rich_line_list(lines)
+class DebugAnalyzer(object):
+    def print_source(self, args, screen_info=None):
+        pass
+    def list_source(self, args, screen_info=None):
+        output = []
+        source_list = []
+        output.extend(self._make_source_table(
+            [item for item in source_list if not item[1]], False))
+        output.extend(self._make_source_table(
+            [item for item in source_list if item[1]], True))
+        _add_main_menu(output, node_name=None)
+        return output
diff --git a/tests/corpus/update.identifier.case-filter/chat.xml b/tests/corpus/update.identifier.case-filter/chat.xml
new file mode 100644
index 0000000..25ba5e6
--- /dev/null
+++ b/tests/corpus/update.identifier.case-filter/chat.xml
@@ -0,0 +1,51 @@
+<no-train>
+```CEDARScript
+UPDATE FILE "1.txt"
+REPLACE WHOLE WITH CASE
+  WHEN PREFIX 'def convert(' THEN SUB
+    r'self, '
+    r''
+  WHEN REGEX r'self\.convert' THEN SUB
+    r'self\.(convert)'
+    r'\1'
+END;
+
+UPDATE FILE "1.py"
+REPLACE WHOLE WITH CASE
+  WHEN PREFIX 'def convert(' THEN SUB
+    r'self, '
+    r''
+  WHEN REGEX r'self\.convert' THEN SUB
+    r'self\.(convert)'
+    r'\1'
+END;
+
+# Update the copied function to remove references to `self`
+UPDATE FUNCTION "_make_source_table"
+  FROM FILE "analyzer_cli.py"
+REPLACE WHOLE WITH CASE
+  WHEN PREFIX '''def _make_source_table(self''' THEN SUB
+    r'''(def _make_source_table\()self, '''
+    r'''\1'''
+  END;
+
+# Update ALL call sites of the method `_make_source_table` to call the new top-level function with the same name
+UPDATE METHOD "DebugAnalyzer.list_source"
+  FROM FILE "analyzer_cli.py"
+REPLACE BODY WITH CASE
+  WHEN REGEX r'''self\._make_source_table''' THEN SUB
+    r'''self\.(_make_source_table)'''
+    r'''\1'''
+END;
+
+UPDATE FILE "1.kts"
+REPLACE WHOLE WITH CASE
+  WHEN LINE 'this.mainClassName = mainVerticleName' THEN SUB
+    r'''this\.mainClassName = mainVerticleName'''
+    'mainClass.set(mainVerticleName)'
+  WHEN LINE '"--launcher-class=${application.mainClassName}",' THEN SUB
+    r'''application\.mainClassName'''
+    'application.mainClass.get()'
+  END;
+```
+</no-train>
diff --git a/tests/corpus/update.identifier.case-filter/expected.1.kts b/tests/corpus/update.identifier.case-filter/expected.1.kts
new file mode 100644
index 0000000..5319355
--- /dev/null
+++ b/tests/corpus/update.identifier.case-filter/expected.1.kts
@@ -0,0 +1,15 @@
+application {
+    mainClass.set(mainVerticleName)
+}
+
+
+named<JavaExec>("run") {
+    doFirst {
+        args = listOf(
+            "run",
+            mainVerticleName,
+            "--launcher-class=${application.mainClass.get()}",
+            "--on-redeploy=$doOnChange"
+        )
+    }
+}
diff --git a/tests/corpus/update.identifier.case-filter/expected.1.py b/tests/corpus/update.identifier.case-filter/expected.1.py
new file mode 100644
index 0000000..eea53f1
--- /dev/null
+++ b/tests/corpus/update.identifier.case-filter/expected.1.py
@@ -0,0 +1,9 @@
+class BaseConverter:
+    def __init(self, s):
+        def encode(self, i):
+            neg, value = (convert(i, self.decimal_digits, self.digits, "-"))
+    def decode(self, s):
+        neg, value = convert(s, self.digits, self.decimal_digits, self.sign)
+def xx(self, number, from_digits, to_digits, sign):
+    def convert(number, from_digits, to_digits, sign):
+def convert(number, from_digits, to_digits, sign):
diff --git a/tests/corpus/update.identifier.case-filter/expected.1.txt b/tests/corpus/update.identifier.case-filter/expected.1.txt
new file mode 100644
index 0000000..1aa4c8e
--- /dev/null
+++ b/tests/corpus/update.identifier.case-filter/expected.1.txt
@@ -0,0 +1,10 @@
+class BaseConverter:
+    def __init(self, s):
+        def encode(self, i):
+            neg, value = (convert(i, self.decimal_digits, self.digits, "-"))
+    def decode(self, s):
+        neg, value = convert(s, self.digits, self.decimal_digits, self.sign)
+def convert(number, from_digits, to_digits, sign):
+    b = def convert(self, number, from_digits, to_digits, sign):
+        def a():
+def convert(number, from_digits, to_digits, sign):
diff --git a/tests/corpus/update.identifier.case-filter/expected.analyzer_cli.py b/tests/corpus/update.identifier.case-filter/expected.analyzer_cli.py
new file mode 100644
index 0000000..cf4584a
--- /dev/null
+++ b/tests/corpus/update.identifier.case-filter/expected.analyzer_cli.py
@@ -0,0 +1,35 @@
+# Copyright 2016 The TensorFlow Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ==============================================================================
+"""CLI Backend for the Analyzer Part of the Debugger.
+
+The analyzer performs post hoc analysis of dumped intermediate tensors and
+graph structure information from debugged Session.run() calls.
+"""
+
+def _make_source_table(source_list, is_tf_py_library):
+    lines=[]
+    return debugger_cli_common.rich_text_lines_from_rich_line_list(lines)
+class DebugAnalyzer(object):
+    def print_source(self, args, screen_info=None):
+        pass
+    def list_source(self, args, screen_info=None):
+        output = []
+        source_list = []
+        output.extend(_make_source_table(
+            [item for item in source_list if not item[1]], False))
+        output.extend(_make_source_table(
+            [item for item in source_list if item[1]], True))
+        _add_main_menu(output, node_name=None)
+        return output
diff --git a/tests/corpus/update.identifier.ed-script-filter!nowindows/1.py b/tests/corpus/update.identifier.ed-script-filter!nowindows/1.py
new file mode 100644
index 0000000..195832d
--- /dev/null
+++ b/tests/corpus/update.identifier.ed-script-filter!nowindows/1.py
@@ -0,0 +1,13 @@
+def calc1(a):
+    return a * 7.0
+def calc2(a):
+    c = ["x", str(calc1(
+        5), "xx")]
+    c = ["x", str(calc1(
+        6), "xx")]
+    c = ["x", str(calc1(#...
+        6), "xx")]
+    # Done...
+    return c
+def calc3(a):
+    return calc1(a)
diff --git a/tests/corpus/update.identifier.ed-script-filter!nowindows/1.txt b/tests/corpus/update.identifier.ed-script-filter!nowindows/1.txt
new file mode 100644
index 0000000..5c969fe
--- /dev/null
+++ b/tests/corpus/update.identifier.ed-script-filter!nowindows/1.txt
@@ -0,0 +1,10 @@
+class BaseConverter:
+    def __init(self, s):
+        def encode(self, i):
+            neg, value = (self.convert(i, self.decimal_digits, self.digits, "-"))
+    def decode(self, s):
+        neg, value = self.convert(s, self.digits, self.decimal_digits, self.sign)
+def convert(self, number, from_digits, to_digits, sign):
+    b = def convert(self, number, from_digits, to_digits, sign):
+        def a():
+def convert(self, number, from_digits, to_digits, sign):
diff --git a/tests/corpus/update.identifier.ed-script-filter!nowindows/chat.xml b/tests/corpus/update.identifier.ed-script-filter!nowindows/chat.xml
new file mode 100644
index 0000000..d46aeb8
--- /dev/null
+++ b/tests/corpus/update.identifier.ed-script-filter!nowindows/chat.xml
@@ -0,0 +1,33 @@
+<no-train>
+```CEDARScript
+UPDATE FILE "1.txt"
+REPLACE WHOLE WITH ED r'''
+
+g/self\.convert/s/self\.\(convert\)/\1/g
+g/^def convert/s/self, //
+
+''';
+
+# 1. Update the function signature of `calc1()` to add parameter `tax: float` as the first one and use the new parameter instead of `7`
+UPDATE FUNCTION "calc1"
+FROM FILE "1.py"
+REPLACE WHOLE WITH ED r'''
+
+g/def calc1/s/(/(tax: float, /
+g/return/s/7.*/tax/
+
+''';
+
+# 2. Update the function signature of `calc2()` to add parameter `base_tax: float = 1.3` as the last one
+# 3. Update ALL call sites of `calc1()` to pass `base_tax` as the first argument
+UPDATE FUNCTION "calc2"
+FROM FILE "1.py"
+REPLACE WHOLE WITH ED r'''
+
+g/def calc2/s/)/, base_tax: float = 1.3)/
+g/calc1(/s/\(calc1(\)/\1base_tax, /
+
+''';
+
+```
+</no-train>
diff --git a/tests/corpus/update.identifier.ed-script-filter!nowindows/expected.1.py b/tests/corpus/update.identifier.ed-script-filter!nowindows/expected.1.py
new file mode 100644
index 0000000..b8bfb44
--- /dev/null
+++ b/tests/corpus/update.identifier.ed-script-filter!nowindows/expected.1.py
@@ -0,0 +1,13 @@
+def calc1(tax: float, a):
+    return a * tax
+def calc2(a, base_tax: float = 1.3):
+    c = ["x", str(calc1(base_tax,
+        5), "xx")]
+    c = ["x", str(calc1(base_tax,
+        6), "xx")]
+    c = ["x", str(calc1(base_tax, #...
+        6), "xx")]
+    # Done...
+    return c
+def calc3(a):
+    return calc1(a)
diff --git a/tests/corpus/update.identifier.ed-script-filter!nowindows/expected.1.txt b/tests/corpus/update.identifier.ed-script-filter!nowindows/expected.1.txt
new file mode 100644
index 0000000..1aa4c8e
--- /dev/null
+++ b/tests/corpus/update.identifier.ed-script-filter!nowindows/expected.1.txt
@@ -0,0 +1,10 @@
+class BaseConverter:
+    def __init(self, s):
+        def encode(self, i):
+            neg, value = (convert(i, self.decimal_digits, self.digits, "-"))
+    def decode(self, s):
+        neg, value = convert(s, self.digits, self.decimal_digits, self.sign)
+def convert(number, from_digits, to_digits, sign):
+    b = def convert(self, number, from_digits, to_digits, sign):
+        def a():
+def convert(number, from_digits, to_digits, sign):
diff --git a/tests/corpus/update.identifier.insert.after.line-number/1.py b/tests/corpus/update.identifier.insert.after.line-number/1.py
new file mode 100644
index 0000000..d22cd49
--- /dev/null
+++ b/tests/corpus/update.identifier.insert.after.line-number/1.py
@@ -0,0 +1,22 @@
+class A:
+    def a(self):
+        pass
+    def calculate(self,
+        a,
+        b,
+        c,
+        d,
+        e
+    ):
+        pass
+class B:
+    def a(self):
+        pass
+    def calculate(self,
+        a,
+        b,
+        c,
+        d,
+        e
+    ):
+        pass
diff --git a/tests/corpus/update.identifier.insert.after.line-number/chat.xml b/tests/corpus/update.identifier.insert.after.line-number/chat.xml
new file mode 100644
index 0000000..fa87a30
--- /dev/null
+++ b/tests/corpus/update.identifier.insert.after.line-number/chat.xml
@@ -0,0 +1,11 @@
+<no-train>
+```CEDARScript
+UPDATE METHOD "calculate" OFFSET 1
+FROM FILE "1.py"
+REPLACE LINE 1
+WITH CONTENT '''
+@0:def calculate(self, line_1,
+@1:line_2,
+''';
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.insert.after.line-number/expected.1.py b/tests/corpus/update.identifier.insert.after.line-number/expected.1.py
new file mode 100644
index 0000000..76ecee3
--- /dev/null
+++ b/tests/corpus/update.identifier.insert.after.line-number/expected.1.py
@@ -0,0 +1,23 @@
+class A:
+    def a(self):
+        pass
+    def calculate(self,
+        a,
+        b,
+        c,
+        d,
+        e
+    ):
+        pass
+class B:
+    def a(self):
+        pass
+    def calculate(self, line_1,
+        line_2,
+        a,
+        b,
+        c,
+        d,
+        e
+    ):
+        pass
diff --git a/tests/corpus/update.identifier.insert.after.line/1.py b/tests/corpus/update.identifier.insert.after.line/1.py
new file mode 100644
index 0000000..25ef1d2
--- /dev/null
+++ b/tests/corpus/update.identifier.insert.after.line/1.py
@@ -0,0 +1,2 @@
+def fun1(a, b):
+    return a + b
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.insert.after.line/chat.xml b/tests/corpus/update.identifier.insert.after.line/chat.xml
new file mode 100644
index 0000000..908a382
--- /dev/null
+++ b/tests/corpus/update.identifier.insert.after.line/chat.xml
@@ -0,0 +1,14 @@
+<no-train>
+```CEDARScript
+UPDATE FUNCTION "fun1"
+FROM FILE "1.py"
+INSERT AFTER LINE "def fun1(a, b):"
+WITH CONTENT '''
+@1:"""Docstring...
+@0:
+@1:Args:
+@2:a
+@1:"""
+''';
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.insert.after.line/expected.1.py b/tests/corpus/update.identifier.insert.after.line/expected.1.py
new file mode 100644
index 0000000..d813a49
--- /dev/null
+++ b/tests/corpus/update.identifier.insert.after.line/expected.1.py
@@ -0,0 +1,7 @@
+def fun1(a, b):
+    """Docstring...
+
+    Args:
+        a
+    """
+    return a + b
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.insert.into.identifier/1.py b/tests/corpus/update.identifier.insert.into.identifier/1.py
new file mode 100644
index 0000000..7b67a90
--- /dev/null
+++ b/tests/corpus/update.identifier.insert.into.identifier/1.py
@@ -0,0 +1,10 @@
+class A:
+    def m0(self):
+        pass
+    def __init__(self, value):
+        self._value = value
+    def m2(self):
+        pass
+class B:
+    def __init__(self):
+        pass
diff --git a/tests/corpus/update.identifier.insert.into.identifier/chat.xml b/tests/corpus/update.identifier.insert.into.identifier/chat.xml
new file mode 100644
index 0000000..439ebb2
--- /dev/null
+++ b/tests/corpus/update.identifier.insert.into.identifier/chat.xml
@@ -0,0 +1,12 @@
+<no-train>
+```CEDARScript
+UPDATE CLASS "A"
+FROM FILE "1.py"
+INSERT INTO METHOD "__init__" BOTTOM
+WITH CONTENT '''
+@-1:def m1(self):
+@0:pass
+''';
+
+```
+</no-train>
diff --git a/tests/corpus/update.identifier.insert.into.identifier/expected.1.py b/tests/corpus/update.identifier.insert.into.identifier/expected.1.py
new file mode 100644
index 0000000..c9e2560
--- /dev/null
+++ b/tests/corpus/update.identifier.insert.into.identifier/expected.1.py
@@ -0,0 +1,12 @@
+class A:
+    def m0(self):
+        pass
+    def __init__(self, value):
+        self._value = value
+    def m1(self):
+        pass
+    def m2(self):
+        pass
+class B:
+    def __init__(self):
+        pass
diff --git a/tests/corpus/update.identifier.move.identifier/1.py b/tests/corpus/update.identifier.move.identifier/1.py
new file mode 100644
index 0000000..a4b7ae7
--- /dev/null
+++ b/tests/corpus/update.identifier.move.identifier/1.py
@@ -0,0 +1,22 @@
+class A:
+    def __init__(self, value):
+        self._value = value
+    def m1(self):
+        pass
+
+
+class B:
+    def m0(self):
+        pass
+    def __init__(self, b):
+        self.b = b
+    def m1(self):
+        pass
+    def m2(self):
+        pass
+
+class C:
+    def __init__(self):
+        pass
+    def m1(self):
+        pass
diff --git a/tests/corpus/update.identifier.move.identifier/chat.xml b/tests/corpus/update.identifier.move.identifier/chat.xml
new file mode 100644
index 0000000..a954ec9
--- /dev/null
+++ b/tests/corpus/update.identifier.move.identifier/chat.xml
@@ -0,0 +1,9 @@
+<no-train>
+```CEDARScript
+UPDATE CLASS "B"
+FROM FILE "1.py"
+MOVE FUNCTION "m1"
+INSERT BEFORE CLASS "C"
+RELATIVE INDENTATION 1;
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.move.identifier/expected.1.py b/tests/corpus/update.identifier.move.identifier/expected.1.py
new file mode 100644
index 0000000..b1d8ee7
--- /dev/null
+++ b/tests/corpus/update.identifier.move.identifier/expected.1.py
@@ -0,0 +1,22 @@
+class A:
+    def __init__(self, value):
+        self._value = value
+    def m1(self):
+        pass
+
+
+class B:
+    def m0(self):
+        pass
+    def __init__(self, b):
+        self.b = b
+    def m2(self):
+        pass
+
+    def m1(self):
+        pass
+class C:
+    def __init__(self):
+        pass
+    def m1(self):
+        pass
diff --git a/tests/corpus/update.identifier.move.whole.1/chat.xml b/tests/corpus/update.identifier.move.whole.1/chat.xml
new file mode 100644
index 0000000..527b753
--- /dev/null
+++ b/tests/corpus/update.identifier.move.whole.1/chat.xml
@@ -0,0 +1,9 @@
+<no-train>
+```CEDARScript
+# A02 becomes local to b
+UPDATE FUNCTION "b" FROM FILE "main.py"
+MOVE WHOLE
+INSERT BEFORE CLASS "A02"
+    RELATIVE INDENTATION -1;
+```
+</no-train>
diff --git a/tests/corpus/update.identifier.move.whole.1/expected.main.py b/tests/corpus/update.identifier.move.whole.1/expected.main.py
new file mode 100644
index 0000000..4cf4661
--- /dev/null
+++ b/tests/corpus/update.identifier.move.whole.1/expected.main.py
@@ -0,0 +1,25 @@
+class A0:
+    ...
+    class A01:
+        ...
+    def b(self, obj, field_name, label):
+        """Check an item of `raw_id_fields`, i.e. check that field named
+        `field_name` exists in model `model` and is a ForeignKey or a
+        ManyToManyField."""
+        class A02:
+            ...
+class A:
+    def a(self):
+        pass
+    def pst(self):
+        pass # 1st
+    def pst(self):
+        pass # 2nd
+class A1:
+    class A2:
+        class A3:
+            pass
+        class A4:
+            pass
+    class A5:
+        pass
diff --git a/tests/corpus/update.identifier.move.whole.1/main.py b/tests/corpus/update.identifier.move.whole.1/main.py
new file mode 100644
index 0000000..7f38a4b
--- /dev/null
+++ b/tests/corpus/update.identifier.move.whole.1/main.py
@@ -0,0 +1,25 @@
+class A0:
+    ...
+    class A01:
+        ...
+        class A02:
+            ...
+class A:
+    def a(self):
+        pass
+    def b(self, obj, field_name, label):
+        """Check an item of `raw_id_fields`, i.e. check that field named
+        `field_name` exists in model `model` and is a ForeignKey or a
+        ManyToManyField."""
+    def pst(self):
+        pass # 1st
+    def pst(self):
+        pass # 2nd
+class A1:
+    class A2:
+        class A3:
+            pass
+        class A4:
+            pass
+    class A5:
+        pass
diff --git a/tests/corpus/update.identifier.move.whole.2/chat.xml b/tests/corpus/update.identifier.move.whole.2/chat.xml
new file mode 100644
index 0000000..d612c4f
--- /dev/null
+++ b/tests/corpus/update.identifier.move.whole.2/chat.xml
@@ -0,0 +1,8 @@
+<no-train>
+```CEDARScript
+# 2nd pst becomes inner of A3
+UPDATE FUNCTION "pst" OFFSET 1 FROM FILE "main.py"
+MOVE WHOLE INSERT AFTER CLASS "A3"
+RELATIVE INDENTATION 1;
+```
+</no-train>
diff --git a/tests/corpus/update.identifier.move.whole.2/expected.main.py b/tests/corpus/update.identifier.move.whole.2/expected.main.py
new file mode 100644
index 0000000..f98367b
--- /dev/null
+++ b/tests/corpus/update.identifier.move.whole.2/expected.main.py
@@ -0,0 +1,25 @@
+class A0:
+    ...
+    class A01:
+        ...
+        class A02:
+            ...
+class A:
+    def a(self):
+        pass
+    def b(self, obj, field_name, label):
+        """Check an item of `raw_id_fields`, i.e. check that field named
+        `field_name` exists in model `model` and is a ForeignKey or a
+        ManyToManyField."""
+    def pst(self):
+        pass # 1st
+class A1:
+    class A2:
+        class A3:
+            pass
+            def pst(self):
+                pass # 2nd
+        class A4:
+            pass
+    class A5:
+        pass
diff --git a/tests/corpus/update.identifier.move.whole.2/main.py b/tests/corpus/update.identifier.move.whole.2/main.py
new file mode 100644
index 0000000..7f38a4b
--- /dev/null
+++ b/tests/corpus/update.identifier.move.whole.2/main.py
@@ -0,0 +1,25 @@
+class A0:
+    ...
+    class A01:
+        ...
+        class A02:
+            ...
+class A:
+    def a(self):
+        pass
+    def b(self, obj, field_name, label):
+        """Check an item of `raw_id_fields`, i.e. check that field named
+        `field_name` exists in model `model` and is a ForeignKey or a
+        ManyToManyField."""
+    def pst(self):
+        pass # 1st
+    def pst(self):
+        pass # 2nd
+class A1:
+    class A2:
+        class A3:
+            pass
+        class A4:
+            pass
+    class A5:
+        pass
diff --git a/tests/corpus/update.identifier.parent-chain/1.py b/tests/corpus/update.identifier.parent-chain/1.py
new file mode 100644
index 0000000..5f411ca
--- /dev/null
+++ b/tests/corpus/update.identifier.parent-chain/1.py
@@ -0,0 +1,24 @@
+class A:
+    def a(self):
+        pass
+    def calculate(self,
+        a,
+        b,
+        c,
+        d,
+        e
+    ):
+        pass
+class Parent1OfB:
+    class Parent2OfB:
+        class B:
+            def a(self):
+                pass
+            def calculate(self,
+                a,
+                b,
+                c,
+                d,
+                e
+            ):
+                pass
diff --git a/tests/corpus/update.identifier.parent-chain/chat.xml b/tests/corpus/update.identifier.parent-chain/chat.xml
new file mode 100644
index 0000000..cf5b3bc
--- /dev/null
+++ b/tests/corpus/update.identifier.parent-chain/chat.xml
@@ -0,0 +1,11 @@
+<no-train>
+```CEDARScript
+UPDATE METHOD "Parent2OfB.B.calculate"
+FROM FILE "1.py"
+REPLACE LINE 1
+WITH CONTENT '''
+@0:def calculate(self, line_1,
+@1:line_2,
+''';
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.parent-chain/expected.1.py b/tests/corpus/update.identifier.parent-chain/expected.1.py
new file mode 100644
index 0000000..345fad6
--- /dev/null
+++ b/tests/corpus/update.identifier.parent-chain/expected.1.py
@@ -0,0 +1,25 @@
+class A:
+    def a(self):
+        pass
+    def calculate(self,
+        a,
+        b,
+        c,
+        d,
+        e
+    ):
+        pass
+class Parent1OfB:
+    class Parent2OfB:
+        class B:
+            def a(self):
+                pass
+            def calculate(self, line_1,
+                line_2,
+                a,
+                b,
+                c,
+                d,
+                e
+            ):
+                pass
diff --git a/tests/corpus/update.identifier.replace.identifier/1.py b/tests/corpus/update.identifier.replace.identifier/1.py
new file mode 100644
index 0000000..9265b7c
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.identifier/1.py
@@ -0,0 +1,16 @@
+class A:
+    def __init__(self, value):
+        self._value = value
+
+
+class B:
+    def m1(self):
+        pass
+    def __init__(self, b):
+        self.b = b
+    def m1(self):
+        pass
+
+class C:
+    def __init__(self):
+        pass
diff --git a/tests/corpus/update.identifier.replace.identifier/chat.xml b/tests/corpus/update.identifier.replace.identifier/chat.xml
new file mode 100644
index 0000000..6688c05
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.identifier/chat.xml
@@ -0,0 +1,11 @@
+<no-train>
+```CEDARScript
+UPDATE CLASS "B"
+FROM FILE "1.py"
+REPLACE FUNCTION "__init__"
+WITH CONTENT '''
+@0:def __init__(self):
+@1:pass  # OK
+''';
+```
+</no-train>
diff --git a/tests/corpus/update.identifier.replace.identifier/expected.1.py b/tests/corpus/update.identifier.replace.identifier/expected.1.py
new file mode 100644
index 0000000..2272478
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.identifier/expected.1.py
@@ -0,0 +1,16 @@
+class A:
+    def __init__(self, value):
+        self._value = value
+
+
+class B:
+    def m1(self):
+        pass
+    def __init__(self):
+        pass  # OK
+    def m1(self):
+        pass
+
+class C:
+    def __init__(self):
+        pass
diff --git a/tests/corpus/update.identifier.replace.line/1.py b/tests/corpus/update.identifier.replace.line/1.py
new file mode 100644
index 0000000..c3edcca
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.line/1.py
@@ -0,0 +1,4 @@
+def fun1(self, a, b, c):
+    def fun2(self, a, b, c):
+        pass
+    pass
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.replace.line/2.py b/tests/corpus/update.identifier.replace.line/2.py
new file mode 100644
index 0000000..5904f72
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.line/2.py
@@ -0,0 +1,13 @@
+class Calculator:
+    pass
+def a():
+    def calculate(
+            a,
+            b
+    ):
+        return a + b
+def helper(
+        a,
+        b
+):
+    pass
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.replace.line/chat.xml b/tests/corpus/update.identifier.replace.line/chat.xml
new file mode 100644
index 0000000..214e186
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.line/chat.xml
@@ -0,0 +1,17 @@
+<no-train>
+```CEDARScript
+UPDATE FUNCTION "fun2"
+FROM FILE "1.py"
+REPLACE LINE "def fun2(self, a, b, c):"
+WITH CONTENT '''
+@0:def fun2(a, b, c):
+''';
+
+UPDATE FUNCTION "calculate"
+FROM FILE "2.py"
+REPLACE LINE "a,"
+WITH CONTENT '''
+@0:a,  # Line replaced
+''';
+```
+</no-train>
diff --git a/tests/corpus/update.identifier.replace.line/expected.1.py b/tests/corpus/update.identifier.replace.line/expected.1.py
new file mode 100644
index 0000000..997af35
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.line/expected.1.py
@@ -0,0 +1,4 @@
+def fun1(self, a, b, c):
+    def fun2(a, b, c):
+        pass
+    pass
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.replace.line/expected.2.py b/tests/corpus/update.identifier.replace.line/expected.2.py
new file mode 100644
index 0000000..0ade43e
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.line/expected.2.py
@@ -0,0 +1,13 @@
+class Calculator:
+    pass
+def a():
+    def calculate(
+            a,  # Line replaced
+            b
+    ):
+        return a + b
+def helper(
+        a,
+        b
+):
+    pass
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.replace.segment.2/1.py b/tests/corpus/update.identifier.replace.segment.2/1.py
new file mode 100644
index 0000000..cea6a0f
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.segment.2/1.py
@@ -0,0 +1,8 @@
+class A:
+    def _(self):
+        pass
+    def calculate(self,
+        a,
+        b
+    ):
+        pass
diff --git a/tests/corpus/update.identifier.replace.segment.2/chat.xml b/tests/corpus/update.identifier.replace.segment.2/chat.xml
new file mode 100644
index 0000000..207095b
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.segment.2/chat.xml
@@ -0,0 +1,13 @@
+<no-train>
+```CEDARScript
+UPDATE METHOD "calculate"
+FROM FILE "1.py"
+REPLACE SEGMENT
+    STARTING AFTER LINE 0
+    ENDING BEFORE LINE 2
+WITH CONTENT '''
+@-1:def calculate(self, line_1,
+@0:line_2,
+''';
+```
+</no-train>
\ No newline at end of file
diff --git a/tests/corpus/update.identifier.replace.segment.2/expected.1.py b/tests/corpus/update.identifier.replace.segment.2/expected.1.py
new file mode 100644
index 0000000..dc74a7f
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.segment.2/expected.1.py
@@ -0,0 +1,9 @@
+class A:
+    def _(self):
+        pass
+    def calculate(self, line_1,
+        line_2,
+        a,
+        b
+    ):
+        pass
diff --git a/tests/corpus/update.identifier.replace.segment/1.py b/tests/corpus/update.identifier.replace.segment/1.py
new file mode 100644
index 0000000..64f4c8d
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.segment/1.py
@@ -0,0 +1,60 @@
+class A:
+    def a(self):
+        def calculate(
+                a,
+                b,
+                c,
+                d,
+                e
+        ):
+            return a + b
+    def helper(
+            a,
+            b
+    ):
+        pass
+class B:
+    def helper0(
+            a,
+            b
+    ):
+        pass
+    def a(self):
+        def calculate(
+                a,
+                b,
+                c,
+                d,
+                e
+        ):
+            return a + b
+    def helper(
+            a,
+            b,
+            e
+    ):
+        pass
+def a(self):
+    def calculate(
+            a,
+            b,
+            c,
+            d,
+            e
+    ):
+        return a + b
+class C:
+    def a(self):
+        def calculate(
+                a,
+                b,
+                c,
+                d,
+                e
+        ):
+            return a + b
+    def helper(
+            a,
+            b
+    ):
+        pass
diff --git a/tests/corpus/update.identifier.replace.segment/chat.xml b/tests/corpus/update.identifier.replace.segment/chat.xml
new file mode 100644
index 0000000..1595d92
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.segment/chat.xml
@@ -0,0 +1,15 @@
+<no-train>
+```CEDARScript
+UPDATE CLASS "B"
+FROM FILE "1.py"
+REPLACE SEGMENT
+STARTING BEFORE LINE "a," OFFSET 1
+ENDING AFTER LINE "e" OFFSET 0
+WITH CONTENT '''
+@-2:def calculate_plus_5(
+@-1:a, b
+@-2:):
+@-1:a += 5
+''';
+```
+</no-train>
diff --git a/tests/corpus/update.identifier.replace.segment/expected.1.py b/tests/corpus/update.identifier.replace.segment/expected.1.py
new file mode 100644
index 0000000..705f6fd
--- /dev/null
+++ b/tests/corpus/update.identifier.replace.segment/expected.1.py
@@ -0,0 +1,57 @@
+class A:
+    def a(self):
+        def calculate(
+                a,
+                b,
+                c,
+                d,
+                e
+        ):
+            return a + b
+    def helper(
+            a,
+            b
+    ):
+        pass
+class B:
+    def helper0(
+            a,
+            b
+    ):
+        pass
+    def a(self):
+        def calculate_plus_5(
+            a, b
+        ):
+            a += 5
+            return a + b
+    def helper(
+            a,
+            b,
+            e
+    ):
+        pass
+def a(self):
+    def calculate(
+            a,
+            b,
+            c,
+            d,
+            e
+    ):
+        return a + b
+class C:
+    def a(self):
+        def calculate(
+                a,
+                b,
+                c,
+                d,
+                e
+        ):
+            return a + b
+    def helper(
+            a,
+            b
+    ):
+        pass
diff --git a/tests/test_corpus.py b/tests/test_corpus.py
new file mode 100644
index 0000000..0408725
--- /dev/null
+++ b/tests/test_corpus.py
@@ -0,0 +1,118 @@
+import re
+import shutil
+import sys
+import tempfile
+import pytest
+from pathlib import Path
+
+from cedarscript_editor import find_commands, CEDARScriptEditor
+
+_no_windows = '!nowindows'
+
+def get_test_cases() -> list[str]:
+    """Get all test cases from tests/corpus directory.
+    
+    Returns:
+    list[str]: Names of all test case directories in the corpus
+    """
+    corpus_dir = Path(__file__).parent / 'corpus'
+    result = [d.name for d in corpus_dir.iterdir() if d.is_dir() and not d.name.startswith('.')]
+    exclusive = [d for d in result if d.casefold().startswith('x.')]
+    return exclusive or result
+
+
+@pytest.fixture
+def editor(tmp_path_factory):
+    """Fixture providing a CEDARScriptEditor instance with a temporary directory.
+    
+    The temporary directory is preserved if the test fails, to help with debugging.
+    It is automatically cleaned up if the test passes.
+    """
+    # Create temp dir under the project's 'out' directory
+    out_dir = Path(__file__).parent.parent / 'out'
+    out_dir.mkdir(exist_ok=True)
+    temp_dir = Path(tempfile.mkdtemp(prefix='test-', dir=out_dir))
+    editor = CEDARScriptEditor(temp_dir)
+    yield editor
+    # Directory will be preserved if test fails (pytest handles this automatically)
+    if not hasattr(editor, "_failed"):  # No failure occurred
+        shutil.rmtree(temp_dir)
+
+
+@pytest.mark.parametrize('test_case', get_test_cases())
+def test_corpus(editor: CEDARScriptEditor, test_case: str):
+    """Test CEDARScript commands from chat.xml files in corpus."""
+    if test_case.casefold().endswith(_no_windows) and sys.platform == 'win32':
+        pytest.skip(f"Cannot run under Windows: {test_case.removesuffix(_no_windows)}")
+
+    try:
+        corpus_dir = Path(__file__).parent / 'corpus'
+        test_dir = corpus_dir / test_case
+
+        # Create scratch area for this test
+        # Copy all files from test dir to scratch area, except chat.xml and expected.*
+        def copy_files(src_dir: Path, dst_dir: Path):
+            for src in src_dir.iterdir():
+                if src.name == 'chat.xml' or src.name.startswith('expected.'):
+                    continue
+                dst = dst_dir / src.name
+                if src.is_dir():
+                    dst.mkdir(exist_ok=True)
+                    copy_files(src, dst)
+                else:
+                    shutil.copy2(src, dst)
+
+        copy_files(test_dir, editor.root_path)
+
+        # Read chat.xml
+        chat_xml = (test_dir / 'chat.xml').read_text()
+
+        # Find and apply commands
+        commands = list(find_commands(chat_xml))
+        assert commands, "No commands found in chat.xml"
+        
+        # Check if test expects an exception
+        throws_match = re.search(r'<throws value="([^"]+)">', chat_xml)
+        if throws_match:
+            expected_error = throws_match.group(1)
+            with pytest.raises(Exception) as excinfo:
+                editor.apply_commands(commands)
+            # TODO excinfo.value is '<description>Unable to find function 'does-not-exist'</description>'
+            actual_error = str(excinfo.value)
+            match re.search(r'<description>(.+)</description>', actual_error):
+                case None:
+                    pass
+                case _ as found:
+                    actual_error = found.group(1)
+            assert actual_error == expected_error, f"Expected error '{expected_error}', but got '{actual_error}'"
+        else:
+            editor.apply_commands(commands)
+
+        def check_expected_files(dir_path: Path):
+            for path in dir_path.iterdir():
+                if path.is_dir():
+                    check_expected_files(path)
+                    continue
+                # Find corresponding expected file in test directory
+                rel_path = path.relative_to(editor.root_path)
+                if str(rel_path).startswith(".") or str(rel_path).endswith("~"):
+                    continue
+                expected_file = test_dir / f"expected.{rel_path}"
+                assert expected_file.exists(), f"'expected.*' file not found: '{expected_file}'"
+
+                expected_content = file_to_lines(expected_file, rel_path)
+                actual_content = file_to_lines(path, rel_path)
+                assert actual_content == expected_content, \
+                    f"Output does not match expected content for {rel_path}"
+
+        check_expected_files(editor.root_path)
+
+    except Exception:
+        editor._failed = True  # Mark as failed to preserve temp directory
+        raise
+
+
+def file_to_lines(file_path, rel_path):
+    expected_content = [f"#{i} [{rel_path}]{c}" for i, c in enumerate(file_path.read_text().splitlines())]
+    return expected_content
+
diff --git a/tests/text_manipulation/test_segment_to_search_range.py b/tests/text_manipulation/test_segment_to_search_range.py
new file mode 100644
index 0000000..7fc44be
--- /dev/null
+++ b/tests/text_manipulation/test_segment_to_search_range.py
@@ -0,0 +1,81 @@
+import pytest
+from cedarscript_ast_parser import RelativeMarker, RelativePositionType, MarkerType
+from text_manipulation import RangeSpec
+from text_manipulation.text_editor_kit import segment_to_search_range
+
+
+def test_basic_segment_search():
+    # Test input
+    lines = """
+#
+#
+#
+#
+#
+#
+def _():
+    pass
+def hello(self):
+    print('hello'),
+    return None,
+    x = 1
+""".strip().splitlines()
+    content = """
+def hello(self, OK):
+    # OK !
+    """.strip().splitlines()
+    expected = """
+#
+#
+#
+#
+#
+#
+def _():
+    pass
+def hello(self, OK):
+    # OK !
+    print('hello'),
+    return None,
+    x = 1
+""".strip().splitlines()
+
+    # To represent a RangeSpec that starts at line 1 and ends at line 1, we must transform from line numbering to indexes.
+    # NOTE: in RangeSpec, we use index values, not line numbers. So for the first line (line 1), the index is 0
+    # Also, the end index is EXCLUSIVE, so if you want a range that covers index 0, use RangeSpec(0, 1)
+
+    # We want to restrict our search to start at line 9 which is `def hello(self):` in array 'lines'
+    # (so it's index 8) and end after the last line
+    search_range = RangeSpec(8, len(lines))
+
+    # Point to line 1 in our search_range (which is line 9 overall which is 'def hello():')
+    # The relative_start_marker points to AFTER line 0, which is line 1 in our search_range
+    relative_start_marker = RelativeMarker(
+        RelativePositionType.AFTER,
+        type=MarkerType.LINE,
+        value=0, # but as this marker is AFTER, it points to line 1
+        marker_subtype='number'
+    )
+    # The relative_end_marker should point to BEFORE line 2, which is line 1 in our search_range.
+    relative_end_marker = relative_start_marker.with_qualifier(RelativePositionType.BEFORE)
+    relative_end_marker.value = 2  # but as this marker is BEFORE, it points to line 1
+
+    # So now, both relative_start_marker and relative_end_marker point to line 1 (relative to the search range)
+    # In terms of indexes, both point to index 0.
+    # When converting this tuple(relative_start_marker, relative_end_marker) to a single absolute RangeSpec,
+    # the expected RangeSpec instance should be RangeSpec(2, 3) which means it corresponds to absolute lines
+    # from line 3 up to line 3 (inclusive).
+
+    # call the method to search inside the range 'search_range' for the segment
+    result: RangeSpec = segment_to_search_range(
+        lines, relative_start_marker, relative_end_marker, search_range=search_range
+    )
+    
+    # Verify results
+    assert result.start == 8, 'start: should be absolute line 9 (so absolute index 8)'
+    assert result.end == 9, "end: should be absolute line 10 (it's exclusive), so should be absolute index 9"
+    assert result.indent == 4, "Indent level should be 4, because line 9 has no indentation"
+
+    result.write(content, lines)
+    # Check the actual content
+    assert lines == expected
