diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000000..3dc9076105c
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,15 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "maven" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "daily"
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: daily
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 46f3230490a..48d2d8afbf9 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -1,13 +1,76 @@
-name: CodeQL call
+
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
on:
push:
- branches: [ "master" ]
+ branches: [ master ]
pull_request:
- branches: [ "master" ]
+ # The branches below must be a subset of the branches above
+ branches: [ master ]
schedule:
- - cron: '15 8 * * 1'
+ - cron: '20 9 * * 2'
jobs:
callCodeQLworkflow:
uses: eclipse-platform/eclipse.platform.releng.aggregator/.github/workflows/codeQLworkflow.yml@master
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'java' ]
+ # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+ # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ - name: Set up Maven
+ uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5
+ with:
+ maven-version: 3.9.9
+ - name: Setup Java JDK
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin' # See 'Supported distributions' for available options
+ java-version: '21'
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+
+ # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+ # queries: security-extended,security-and-quality
+
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # For Tycho/Eclipse projects, we need to use explicit Maven build instead
+ - name: Build with Maven
+ run: mvn -B package -Pbuild-individual-bundles --file pom.xml
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
+ env:
+ CODEQL_ACTION_EXTRA_OPTIONS: '{"database":{"interpret-results":["--max-paths", 1]}}'
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
new file mode 100644
index 00000000000..cbca6233f9b
--- /dev/null
+++ b/.github/workflows/maven.yml
@@ -0,0 +1,30 @@
+# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time
+# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
+
+name: Java CI with Maven
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: maven
+ - name: Set up Maven
+ uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5
+ with:
+ maven-version: 3.9.9
+ - name: Build with Maven
+ run: mvn -B package -Pbuild-individual-bundles --file pom.xml
diff --git a/.github/workflows/rebase-upstream.yml b/.github/workflows/rebase-upstream.yml
new file mode 100644
index 00000000000..37b6f45db7c
--- /dev/null
+++ b/.github/workflows/rebase-upstream.yml
@@ -0,0 +1,221 @@
+name: Rebase Upstream
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request:
+ types: [opened]
+
+jobs:
+ add-rebase-button:
+ name: Add Rebase Instructions
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ steps:
+ - name: Add comment with rebase instructions
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const repoUrl = context.payload.repository.html_url;
+ const workflowUrl = `${repoUrl}/actions/workflows/rebase-upstream.yml`;
+
+ const comment = `## π Rebase onto Upstream
+
+ To rebase this PR onto the latest \`eclipse-jdt/eclipse.jdt.ui:master\`, comment:
+
+ \`\`\`
+ /rebase-upstream
+ \`\`\`
+
+ This will rebase your PR branch onto the upstream repository's master branch.
+
+ π [View Workflow](${workflowUrl})`;
+
+ github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: comment
+ });
+
+ rebase-upstream:
+ name: Rebase onto Upstream Master
+ if: |
+ github.event_name == 'issue_comment' &&
+ github.event.issue.pull_request &&
+ contains(github.event.comment.body, '/rebase-upstream')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - name: Check user permission
+ id: check
+ uses: actions/github-script@v7
+ with:
+ result-encoding: string
+ script: |
+ const authorAssociation = context.payload.comment.author_association;
+ const allowedRoles = ['OWNER', 'MEMBER', 'COLLABORATOR'];
+
+ if (allowedRoles.includes(authorAssociation)) {
+ return 'true';
+ } else {
+ return 'false';
+ }
+
+ - name: Add unauthorized reaction
+ if: steps.check.outputs.result != 'true'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.reactions.createForIssueComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: context.payload.comment.id,
+ content: '-1'
+ });
+
+ github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: 'β Only repository collaborators can trigger the rebase.'
+ });
+
+ - name: Exit if unauthorized
+ if: steps.check.outputs.result != 'true'
+ run: exit 1
+
+ - name: Add rocket reaction
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.reactions.createForIssueComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: context.payload.comment.id,
+ content: 'rocket'
+ });
+
+ - name: Get PR branch info
+ id: pr
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const { data: pr } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.issue.number
+ });
+
+ core.setOutput('head_ref', pr.head.ref);
+ core.setOutput('head_repo', pr.head.repo.full_name);
+ core.setOutput('head_clone_url', pr.head.repo.clone_url);
+
+ - name: Checkout PR branch
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ steps.pr.outputs.head_repo }}
+ ref: ${{ steps.pr.outputs.head_ref }}
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Configure Git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Add upstream remote and rebase
+ id: rebase
+ run: |
+ git remote add upstream https://github.com/eclipse-jdt/eclipse.jdt.ui.git
+ git fetch upstream master
+ git fetch origin master
+
+ # Find where PR branch diverged from fork master
+ FORK_POINT=$(git merge-base HEAD origin/master)
+ echo "Fork point: $FORK_POINT"
+
+ # Rebase only PR commits onto upstream, skipping fork-specific commits
+ if git rebase --onto upstream/master $FORK_POINT HEAD; then
+ echo "success=true" >> $GITHUB_OUTPUT
+ else
+ echo "success=false" >> $GITHUB_OUTPUT
+ git rebase --abort
+ fi
+
+ - name: Push rebased branch
+ if: steps.rebase.outputs.success == 'true'
+ run: |
+ git push --force-with-lease origin ${{ steps.pr.outputs.head_ref }}
+
+ - name: Add success comment
+ if: steps.rebase.outputs.success == 'true'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.reactions.createForIssueComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: context.payload.comment.id,
+ content: '+1'
+ });
+
+ github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: 'β
Successfully rebased onto `eclipse-jdt/eclipse.jdt.ui:master`'
+ });
+
+ - name: Add failure comment
+ if: steps.rebase.outputs.success == 'false'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.reactions.createForIssueComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: context.payload.comment.id,
+ content: '-1'
+ });
+
+ const comment = `β Rebase failed due to conflicts.
+
+ To resolve manually:
+
+ \`\`\`bash
+ # Add upstream remote
+ git remote add upstream https://github.com/eclipse-jdt/eclipse.jdt.ui.git
+
+ # Fetch upstream and origin
+ git fetch upstream master
+ git fetch origin master
+
+ # Find where PR branch diverged from fork master
+ FORK_POINT=$(git merge-base HEAD origin/master)
+
+ # Rebase only PR commits onto upstream/master
+ git rebase --onto upstream/master $FORK_POINT HEAD
+
+ # Resolve conflicts, then:
+ git add .
+ git rebase --continue
+
+ # Force push when done
+ git push --force-with-lease
+ \`\`\``;
+
+ github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: comment
+ });
+
+ - name: Fail workflow if rebase failed
+ if: steps.rebase.outputs.success == 'false'
+ run: exit 1
diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml
index f817d6f6009..01e06cb1806 100644
--- a/.github/workflows/rebase.yml
+++ b/.github/workflows/rebase.yml
@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the latest code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # otherwise, you will fail to push refs to dest repo
- name: Automatic Rebase
- uses: cirrus-actions/rebase@1.7
+ uses: cirrus-actions/rebase@1.8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml
new file mode 100644
index 00000000000..4fba1c015ff
--- /dev/null
+++ b/.github/workflows/sync-upstream.yml
@@ -0,0 +1,289 @@
+name: Sync Fork with Upstream
+
+on:
+ schedule:
+ - cron: '0 3 * * *' # Daily at 3:00 AM UTC
+ workflow_dispatch:
+ issue_comment:
+ types: [created]
+
+jobs:
+ sync-scheduled:
+ name: Sync Fork (Scheduled)
+ if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout fork master
+ uses: actions/checkout@v4
+ with:
+ ref: master
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Configure Git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Add upstream remote
+ run: |
+ git remote add upstream https://github.com/eclipse-jdt/eclipse.jdt.ui.git
+ git fetch upstream master
+
+ - name: Identify and backup fork-specific files
+ id: backup
+ run: |
+ # Create temporary directory for fork-specific files
+ mkdir -p /tmp/fork-specific
+
+ # Identify fork-specific workflow files (files that don't exist in upstream)
+ git fetch upstream master
+
+ # Get list of workflow files in fork
+ FORK_WORKFLOWS=$(git ls-tree -r HEAD --name-only .github/workflows/ 2>/dev/null || echo "")
+
+ # For each workflow file in fork, check if it exists in upstream
+ for file in $FORK_WORKFLOWS; do
+ if ! git cat-file -e upstream/master:"$file" 2>/dev/null; then
+ echo "Fork-specific file: $file"
+ # Create directory structure and copy file
+ mkdir -p "/tmp/fork-specific/$(dirname "$file")"
+ cp "$file" "/tmp/fork-specific/$file"
+ fi
+ done
+
+ # Check if any fork-specific files were found
+ if [ -d "/tmp/fork-specific/.github" ]; then
+ echo "has_fork_files=true" >> $GITHUB_OUTPUT
+ echo "Found fork-specific files:"
+ find /tmp/fork-specific -type f
+ else
+ echo "has_fork_files=false" >> $GITHUB_OUTPUT
+ echo "No fork-specific files found"
+ fi
+
+ - name: Reset to upstream master
+ run: |
+ # Reset fork master to upstream master
+ git reset --hard upstream/master
+
+ - name: Restore fork-specific files
+ if: steps.backup.outputs.has_fork_files == 'true'
+ run: |
+ # Copy fork-specific files back
+ if [ -d "/tmp/fork-specific" ]; then
+ cp -r /tmp/fork-specific/.github ./ 2>/dev/null || true
+ fi
+
+ # Add all fork-specific changes
+ git add .github/
+
+ - name: Commit fork-specific changes
+ if: steps.backup.outputs.has_fork_files == 'true'
+ run: |
+ # Check if there are changes to commit
+ if ! git diff --cached --quiet; then
+ git commit -m "Fork-specific CI and workflow configurations"
+ echo "β
Fork-specific changes committed"
+ else
+ echo "βΉοΈ No fork-specific changes to commit"
+ fi
+
+ - name: Push to fork master
+ run: |
+ git push --force origin master
+ echo "β
Successfully synced with upstream"
+
+ sync-manual:
+ name: Sync Fork (Manual)
+ if: |
+ github.event_name == 'issue_comment' &&
+ contains(github.event.comment.body, '/sync-upstream')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ issues: write
+ steps:
+ - name: Check user permission
+ id: check
+ uses: actions/github-script@v7
+ with:
+ result-encoding: string
+ script: |
+ const authorAssociation = context.payload.comment.author_association;
+ const allowedRoles = ['OWNER', 'MEMBER', 'COLLABORATOR'];
+
+ if (allowedRoles.includes(authorAssociation)) {
+ return 'true';
+ } else {
+ return 'false';
+ }
+
+ - name: Add unauthorized reaction
+ if: steps.check.outputs.result != 'true'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.reactions.createForIssueComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: context.payload.comment.id,
+ content: '-1'
+ });
+
+ github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: 'β Only repository collaborators can trigger the sync.'
+ });
+
+ - name: Exit if unauthorized
+ if: steps.check.outputs.result != 'true'
+ run: exit 1
+
+ - name: Add rocket reaction
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.reactions.createForIssueComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: context.payload.comment.id,
+ content: 'rocket'
+ });
+
+ - name: Checkout fork master
+ uses: actions/checkout@v4
+ with:
+ ref: master
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Configure Git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Add upstream remote
+ run: |
+ git remote add upstream https://github.com/eclipse-jdt/eclipse.jdt.ui.git
+ git fetch upstream master
+
+ - name: Identify and backup fork-specific files
+ id: backup
+ run: |
+ # Create temporary directory for fork-specific files
+ mkdir -p /tmp/fork-specific
+
+ # Identify fork-specific workflow files (files that don't exist in upstream)
+ git fetch upstream master
+
+ # Get list of workflow files in fork
+ FORK_WORKFLOWS=$(git ls-tree -r HEAD --name-only .github/workflows/ 2>/dev/null || echo "")
+
+ # For each workflow file in fork, check if it exists in upstream
+ for file in $FORK_WORKFLOWS; do
+ if ! git cat-file -e upstream/master:"$file" 2>/dev/null; then
+ echo "Fork-specific file: $file"
+ # Create directory structure and copy file
+ mkdir -p "/tmp/fork-specific/$(dirname "$file")"
+ cp "$file" "/tmp/fork-specific/$file"
+ fi
+ done
+
+ # Check if any fork-specific files were found
+ if [ -d "/tmp/fork-specific/.github" ]; then
+ echo "has_fork_files=true" >> $GITHUB_OUTPUT
+ echo "Found fork-specific files:"
+ find /tmp/fork-specific -type f
+ else
+ echo "has_fork_files=false" >> $GITHUB_OUTPUT
+ echo "No fork-specific files found"
+ fi
+
+ - name: Reset to upstream master
+ id: reset
+ run: |
+ # Reset fork master to upstream master
+ git reset --hard upstream/master
+ echo "success=true" >> $GITHUB_OUTPUT
+
+ - name: Restore fork-specific files
+ if: steps.backup.outputs.has_fork_files == 'true'
+ run: |
+ # Copy fork-specific files back
+ if [ -d "/tmp/fork-specific" ]; then
+ cp -r /tmp/fork-specific/.github ./ 2>/dev/null || true
+ fi
+
+ # Add all fork-specific changes
+ git add .github/
+
+ - name: Commit fork-specific changes
+ if: steps.backup.outputs.has_fork_files == 'true'
+ run: |
+ # Check if there are changes to commit
+ if ! git diff --cached --quiet; then
+ git commit -m "Fork-specific CI and workflow configurations"
+ echo "β
Fork-specific changes committed"
+ else
+ echo "βΉοΈ No fork-specific changes to commit"
+ fi
+
+ - name: Push to fork master
+ id: push
+ run: |
+ if git push --force origin master; then
+ echo "success=true" >> $GITHUB_OUTPUT
+ else
+ echo "success=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Add success comment
+ if: steps.push.outputs.success == 'true'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.reactions.createForIssueComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: context.payload.comment.id,
+ content: '+1'
+ });
+
+ github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: 'β
Successfully synced fork master with `eclipse-jdt/eclipse.jdt.ui:master`'
+ });
+
+ - name: Add failure comment
+ if: steps.push.outputs.success == 'false'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.reactions.createForIssueComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: context.payload.comment.id,
+ content: '-1'
+ });
+
+ const comment = `β Sync failed. Please check the workflow logs for details.
+
+ [View workflow run](${context.payload.repository.html_url}/actions/runs/${context.runId})`;
+
+ github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: comment
+ });
+
+ - name: Fail workflow if push failed
+ if: steps.push.outputs.success == 'false'
+ run: exit 1
diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml
index 13928340c29..1bea74fa702 100644
--- a/.mvn/extensions.xml
+++ b/.mvn/extensions.xml
@@ -3,6 +3,6 @@
org.eclipse.tycho
tycho-build
- 5.0.0
+ 5.0.1
diff --git a/README.md b/README.md
index 3d78a6c4753..b14394c3a42 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,19 @@
# JDT UI
+[](https://github.com/carstenartur/eclipse.jdt.ui/actions/workflows/maven.yml)
+
+[](https://github.com/carstenartur/eclipse.jdt.ui/actions/workflows/verifyFreezePeriod.yml)
+
+[](https://github.com/carstenartur/eclipse.jdt.ui/actions/workflows/codeql.yml)
+
+[](https://github.com/carstenartur/eclipse.jdt.ui/actions/workflows/codacy.yml)
+
+Thanks for your interest in this project.
+
This is the UI part of Eclipse's Java development tools, the user interface for the Java IDE.
This includes views like Package Explorer and JUnit, the Java and properties files editors, Java search, and refactorings.
+
For more information and important links, refer to the [JDT wiki page](https://github.com/eclipse-jdt/eclipse.jdt.core/wiki) or the [JDT project overview page](https://projects.eclipse.org/projects/eclipse.jdt).
diff --git a/org.eclipse.jdt.text.tests/src/org/eclipse/jdt/text/tests/DocumentDirtyTrackerTest.java b/org.eclipse.jdt.text.tests/src/org/eclipse/jdt/text/tests/DocumentDirtyTrackerTest.java
new file mode 100644
index 00000000000..fc2a39049d9
--- /dev/null
+++ b/org.eclipse.jdt.text.tests/src/org/eclipse/jdt/text/tests/DocumentDirtyTrackerTest.java
@@ -0,0 +1,255 @@
+/*******************************************************************************
+ * Copyright (c) 2025 IBM Corporation and others.
+ *
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * IBM Corporation - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.jdt.text.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.eclipse.jface.text.Document;
+import org.eclipse.jface.text.IDocument;
+import org.eclipse.jface.text.IRegion;
+
+import org.eclipse.jdt.internal.ui.javaeditor.DocumentDirtyTracker;
+
+/**
+ * Tests for DocumentDirtyTracker to ensure it correctly tracks dirty lines
+ * and prevents race conditions in format-on-save operations.
+ */
+public class DocumentDirtyTrackerTest {
+
+ private IDocument document;
+ private DocumentDirtyTracker tracker;
+
+ @BeforeEach
+ public void setUp() {
+ document = new Document();
+ tracker = DocumentDirtyTracker.get(document);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ if (tracker != null) {
+ tracker.dispose();
+ }
+ }
+
+ @Test
+ public void testInitiallyNoDirtyRegions() {
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNull(regions, "Should have no dirty regions initially");
+ }
+
+ @Test
+ public void testSingleLineEdit() {
+ document.set("line1\nline2\nline3\n");
+ tracker.clearDirtyLines(); // Clear initial dirty marks
+
+ // Edit line 1
+ document.set("modified1\nline2\nline3\n");
+
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNotNull(regions, "Should have dirty regions after edit");
+ assertEquals(1, regions.length, "Should have 1 dirty region");
+ }
+
+ @Test
+ public void testMultipleConsecutiveLines() {
+ document.set("line1\nline2\nline3\nline4\n");
+ tracker.clearDirtyLines();
+
+ // Edit lines 1 and 2
+ document.set("modified1\nmodified2\nline3\nline4\n");
+
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNotNull(regions, "Should have dirty regions");
+ assertEquals(1, regions.length, "Should merge consecutive lines into 1 region");
+ }
+
+ @Test
+ public void testNonConsecutiveLines() {
+ document.set("line1\nline2\nline3\nline4\n");
+ tracker.clearDirtyLines();
+
+ // Mark lines 0 and 2 as dirty manually
+ tracker.markLinesDirty(0, 2);
+
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNotNull(regions, "Should have dirty regions");
+ assertEquals(2, regions.length, "Should have 2 separate regions");
+ }
+
+ @Test
+ public void testLineInsertion() {
+ document.set("line1\nline2\nline3\n");
+ tracker.clearDirtyLines();
+
+ // Mark line 1 as dirty
+ tracker.markLinesDirty(1);
+
+ // Insert a line before line 1
+ document.set("line1\ninserted\nline2\nline3\n");
+
+ // The dirty line should have shifted from 1 to 2
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNotNull(regions, "Should still have dirty regions after insertion");
+ }
+
+ @Test
+ public void testLineDeletion() {
+ document.set("line1\nline2\nline3\nline4\n");
+ tracker.clearDirtyLines();
+
+ // Mark line 2 as dirty
+ tracker.markLinesDirty(2);
+
+ // Delete line 1
+ document.set("line1\nline3\nline4\n");
+
+ // The dirty line should have shifted from 2 to 1
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNotNull(regions, "Should still have dirty regions after deletion");
+ }
+
+ @Test
+ public void testClearDirtyLines() {
+ document.set("line1\nline2\nline3\n");
+ tracker.clearDirtyLines();
+
+ // Edit a line
+ document.set("modified1\nline2\nline3\n");
+
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNotNull(regions, "Should have dirty regions before clear");
+
+ // Clear dirty lines
+ tracker.clearDirtyLines();
+
+ regions = tracker.getDirtyRegions();
+ assertNull(regions, "Should have no dirty regions after clear");
+ }
+
+ @Test
+ public void testUTF8Characters() {
+ // Test with UTF-8 characters including emojis
+ document.set("Hello δΈη\nδ½ ε₯½ World\nEmoji ππ\n");
+ tracker.clearDirtyLines();
+
+ // Edit the emoji line
+ document.set("Hello δΈη\nδ½ ε₯½ World\nModified πβ¨\n");
+
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNotNull(regions, "Should handle UTF-8 characters correctly");
+ assertEquals(1, regions.length, "Should have 1 dirty region");
+ }
+
+ @Test
+ public void testRapidSuccessiveEdits() {
+ document.set("line1\nline2\nline3\n");
+ tracker.clearDirtyLines();
+
+ // Simulate rapid successive edits on different lines
+ document.set("mod1\nline2\nline3\n");
+ document.set("mod1\nmod2\nline3\n");
+ document.set("mod1\nmod2\nmod3\n");
+
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNotNull(regions, "Should track all rapid edits");
+ // All lines should be marked as dirty
+ assertEquals(1, regions.length, "All consecutive lines should be in 1 region");
+ }
+
+ @Test
+ public void testEmptyDocument() {
+ document.set("");
+ tracker.clearDirtyLines();
+
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNull(regions, "Empty document should have no dirty regions");
+ }
+
+ @Test
+ public void testSingleLineDocument() {
+ document.set("single line");
+ tracker.clearDirtyLines();
+
+ // Edit the single line
+ document.set("modified line");
+
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNotNull(regions, "Should have dirty regions for single line edit");
+ assertEquals(1, regions.length, "Should have 1 dirty region");
+ }
+
+ @Test
+ public void testRegionBounds() {
+ document.set("line1\nline2\nline3\n");
+ tracker.clearDirtyLines();
+
+ // Edit line 1
+ document.set("modified1\nline2\nline3\n");
+
+ IRegion[] regions = tracker.getDirtyRegions();
+ assertNotNull(regions, "Should have regions");
+
+ // Verify region is within document bounds
+ for (IRegion region : regions) {
+ int offset = region.getOffset();
+ int length = region.getLength();
+ int docLength = document.getLength();
+
+ assertTrue(offset >= 0, "Offset should be non-negative");
+ assertTrue(length >= 0, "Length should be non-negative");
+ assertTrue(offset + length <= docLength, "Region should be within document bounds");
+ }
+ }
+
+ @Test
+ public void testMultipleDocuments() {
+ // Test that different documents have independent trackers
+ IDocument doc1 = new Document("doc1 line1\ndoc1 line2\n");
+ IDocument doc2 = new Document("doc2 line1\ndoc2 line2\n");
+
+ DocumentDirtyTracker tracker1 = DocumentDirtyTracker.get(doc1);
+ DocumentDirtyTracker tracker2 = DocumentDirtyTracker.get(doc2);
+
+ tracker1.clearDirtyLines();
+ tracker2.clearDirtyLines();
+
+ // Edit only doc1
+ doc1.set("doc1 modified\ndoc1 line2\n");
+
+ IRegion[] regions1 = tracker1.getDirtyRegions();
+ IRegion[] regions2 = tracker2.getDirtyRegions();
+
+ assertNotNull(regions1, "Doc1 should have dirty regions");
+ assertNull(regions2, "Doc2 should not have dirty regions");
+
+ tracker1.dispose();
+ tracker2.dispose();
+ }
+
+ @Test
+ public void testSameDocumentReturnsSameTracker() {
+ DocumentDirtyTracker tracker1 = DocumentDirtyTracker.get(document);
+ DocumentDirtyTracker tracker2 = DocumentDirtyTracker.get(document);
+
+ assertEquals(tracker1, tracker2, "Same document should return same tracker instance");
+ }
+}
diff --git a/org.eclipse.jdt.text.tests/src/org/eclipse/jdt/text/tests/JdtTextTestSuite.java b/org.eclipse.jdt.text.tests/src/org/eclipse/jdt/text/tests/JdtTextTestSuite.java
index ca28aa2ca08..276d2dd4b8f 100644
--- a/org.eclipse.jdt.text.tests/src/org/eclipse/jdt/text/tests/JdtTextTestSuite.java
+++ b/org.eclipse.jdt.text.tests/src/org/eclipse/jdt/text/tests/JdtTextTestSuite.java
@@ -35,6 +35,7 @@
@SelectClasses({
PluginsNotLoadedTest.class,
CompilationUnitDocumentProviderTest.class,
+ DocumentDirtyTrackerTest.class,
JavaHeuristicScannerTest.class,
JavaAutoIndentStrategyTest.class,
JavaBreakIteratorTest.class,
diff --git a/org.eclipse.jdt.ui/core extension/org/eclipse/jdt/internal/corext/fix/CleanUpPostSaveListener.java b/org.eclipse.jdt.ui/core extension/org/eclipse/jdt/internal/corext/fix/CleanUpPostSaveListener.java
index 53cb2de4b43..dc66da33648 100644
--- a/org.eclipse.jdt.ui/core extension/org/eclipse/jdt/internal/corext/fix/CleanUpPostSaveListener.java
+++ b/org.eclipse.jdt.ui/core extension/org/eclipse/jdt/internal/corext/fix/CleanUpPostSaveListener.java
@@ -105,6 +105,7 @@
import org.eclipse.jdt.internal.ui.dialogs.OptionalMessageDialog;
import org.eclipse.jdt.internal.ui.fix.IMultiLineCleanUp.MultiLineCleanUpContext;
import org.eclipse.jdt.internal.ui.fix.MapCleanUpOptions;
+import org.eclipse.jdt.internal.ui.javaeditor.DocumentDirtyTracker;
import org.eclipse.jdt.internal.ui.javaeditor.saveparticipant.IPostSaveListener;
import org.eclipse.jdt.internal.ui.javaeditor.saveparticipant.SaveParticipantPreferenceConfigurationConstants;
import org.eclipse.jdt.internal.ui.preferences.BulletListBlock;
@@ -305,6 +306,7 @@ public void widgetSelected(SelectionEvent e) {
private static final String WARNING_VALUE= "warning"; //$NON-NLS-1$
private static final String ERROR_VALUE= "error"; //$NON-NLS-1$
private static final String CHANGED_REGION_POSITION_CATEGORY= "changed_region_position_category"; //$NON-NLS-1$
+ private static final NullProgressMonitor NULL_PROGRESS_MONITOR = new NullProgressMonitor();
private static boolean FIRST_CALL= false;
private static boolean FIRST_CALL_DONE= false;
@@ -326,10 +328,26 @@ public void saved(ICompilationUnit unit, IRegion[] changedRegions, IProgressMoni
return;
ICleanUp[] cleanUps= getCleanUps(unit.getJavaProject().getProject());
+ boolean needsChangedRegions = requiresChangedRegions(cleanUps);
long oldFileValue= unit.getResource().getModificationStamp();
long oldDocValue= getDocumentStamp((IFile)unit.getResource(), Progress.subMonitor(monitor, 2));
+ // Use DocumentDirtyTracker to get current dirty regions instead of stale changedRegions
+ // This prevents race conditions where regions become invalid between calculation and use
+ IRegion[] regionsToFormat = changedRegions;
+ if (needsChangedRegions) {
+ IDocument document = getDocument(unit);
+ if (document != null) {
+ DocumentDirtyTracker tracker = DocumentDirtyTracker.get(document);
+ IRegion[] dirtyRegions = tracker.getDirtyRegions();
+ if (dirtyRegions != null) {
+ // Validate regions before using them
+ regionsToFormat = validateRegions(dirtyRegions, document);
+ }
+ }
+ }
+
CompositeChange result= new CompositeChange(FixMessages.CleanUpPostSaveListener_SaveAction_ChangeName);
LinkedList undoEdits= new LinkedList<>();
@@ -379,10 +397,10 @@ public void saved(ICompilationUnit unit, IRegion[] changedRegions, IProgressMoni
}
CleanUpContext context;
- if (changedRegions == null) {
+ if (regionsToFormat == null) {
context= new CleanUpContext(unit, ast);
} else {
- context= new MultiLineCleanUpContext(unit, ast, changedRegions);
+ context= new MultiLineCleanUpContext(unit, ast, regionsToFormat);
}
ArrayList undoneCleanUps= new ArrayList<>();
@@ -406,8 +424,8 @@ public void saved(ICompilationUnit unit, IRegion[] changedRegions, IProgressMoni
PerformChangeOperation performChangeOperation= new PerformChangeOperation(change);
performChangeOperation.setSchedulingRule(unit.getSchedulingRule());
- if (changedRegions != null && changedRegions.length > 0 && requiresChangedRegions(cleanUps)) {
- changedRegions= performWithChangedRegionUpdate(performChangeOperation, changedRegions, unit, Progress.subMonitor(monitor, 5));
+ if (regionsToFormat != null && regionsToFormat.length > 0 && requiresChangedRegions(cleanUps)) {
+ regionsToFormat= performWithChangedRegionUpdate(performChangeOperation, regionsToFormat, unit, Progress.subMonitor(monitor, 5));
} else {
performChangeOperation.run(Progress.subMonitor(monitor, 5));
}
@@ -420,6 +438,15 @@ public void saved(ICompilationUnit unit, IRegion[] changedRegions, IProgressMoni
} finally {
manager.changePerformed(result, success);
}
+
+ // Clear dirty lines after successful formatting (if we used the tracker)
+ if (success && needsChangedRegions) {
+ IDocument document = getDocument(unit);
+ if (document != null) {
+ DocumentDirtyTracker tracker = DocumentDirtyTracker.get(document);
+ tracker.clearDirtyLines();
+ }
+ }
if (undoEdits.size() > 0) {
UndoEdit[] undoEditArray= undoEdits.toArray(new UndoEdit[undoEdits.size()]);
@@ -655,6 +682,84 @@ private CoreException wrapBadPositionCategoryException(BadPositionCategoryExcept
return new CoreException(new Status(IStatus.ERROR, JavaUI.ID_PLUGIN, 0, message, e));
}
+ /**
+ * Gets the document for the given compilation unit.
+ *
+ * @param unit the compilation unit
+ * @return the document, or null if not available
+ * @throws CoreException if an error occurs
+ */
+ private IDocument getDocument(ICompilationUnit unit) throws CoreException {
+ final ITextFileBufferManager manager= FileBuffers.getTextFileBufferManager();
+ final IPath path= unit.getResource().getFullPath();
+
+ ITextFileBuffer buffer= null;
+ try {
+ manager.connect(path, LocationKind.IFILE, NULL_PROGRESS_MONITOR);
+ buffer= manager.getTextFileBuffer(path, LocationKind.IFILE);
+ return buffer != null ? buffer.getDocument() : null;
+ } finally {
+ if (buffer != null)
+ manager.disconnect(path, LocationKind.IFILE, NULL_PROGRESS_MONITOR);
+ }
+ }
+
+ /**
+ * Validates regions against the current document state, filtering out any invalid regions.
+ * This provides defensive bounds checking to prevent StringIndexOutOfBoundsException.
+ *
+ * @param regions the regions to validate
+ * @param document the document to validate against
+ * @return the validated regions, or null if all regions are invalid
+ */
+ private IRegion[] validateRegions(IRegion[] regions, IDocument document) {
+ if (regions == null || regions.length == 0 || document == null) {
+ return regions;
+ }
+
+ ArrayList validRegions = new ArrayList<>();
+ int docLength = document.getLength();
+
+ for (IRegion region : regions) {
+ if (region != null && isValidRegion(region, docLength)) {
+ validRegions.add(region);
+ }
+ }
+
+ return validRegions.isEmpty() ? null : validRegions.toArray(new IRegion[validRegions.size()]);
+ }
+
+ /**
+ * Checks if a region is valid for the given document length.
+ * A region is valid if:
+ * - Its offset and length are non-negative
+ * - The region doesn't extend beyond the document bounds
+ * - For non-empty regions: offset must be within document content (< docLength)
+ * - For empty regions: offset can be at document end (== docLength) for cursor positioning
+ *
+ * @param region the region to validate
+ * @param docLength the document length
+ * @return true if the region is valid
+ */
+ private boolean isValidRegion(IRegion region, int docLength) {
+ int offset = region.getOffset();
+ int length = region.getLength();
+
+ // Basic validity checks
+ if (offset < 0 || length < 0) {
+ return false;
+ }
+
+ // Check that region doesn't extend beyond document
+ if (offset + length > docLength) {
+ return false;
+ }
+
+ // Empty regions at end are valid (for cursor positioning)
+ // Non-empty regions must start within document content
+ return length == 0 || offset < docLength;
+ }
+
private void showSlowCleanUpsWarning(HashSet slowCleanUps) {
final StringBuilder cleanUpNames= new StringBuilder();
diff --git a/org.eclipse.jdt.ui/ui/org/eclipse/jdt/internal/ui/javaeditor/CompilationUnitDocumentProvider.java b/org.eclipse.jdt.ui/ui/org/eclipse/jdt/internal/ui/javaeditor/CompilationUnitDocumentProvider.java
index 36c5acbd317..e9250552071 100644
--- a/org.eclipse.jdt.ui/ui/org/eclipse/jdt/internal/ui/javaeditor/CompilationUnitDocumentProvider.java
+++ b/org.eclipse.jdt.ui/ui/org/eclipse/jdt/internal/ui/javaeditor/CompilationUnitDocumentProvider.java
@@ -1250,6 +1250,15 @@ protected void disposeFileInfo(Object element, FileInfo info) {
@Override
public void connect(Object element) throws CoreException {
super.connect(element);
+
+ // Initialize DocumentDirtyTracker for the document.
+ // The get() method has the side effect of creating and registering
+ // a document listener if one doesn't exist yet.
+ IDocument document= getDocument(element);
+ if (document != null) {
+ DocumentDirtyTracker.get(document);
+ }
+
if (getFileInfo(element) != null)
return;
diff --git a/org.eclipse.jdt.ui/ui/org/eclipse/jdt/internal/ui/javaeditor/DocumentDirtyTracker.java b/org.eclipse.jdt.ui/ui/org/eclipse/jdt/internal/ui/javaeditor/DocumentDirtyTracker.java
new file mode 100644
index 00000000000..f305f6809d0
--- /dev/null
+++ b/org.eclipse.jdt.ui/ui/org/eclipse/jdt/internal/ui/javaeditor/DocumentDirtyTracker.java
@@ -0,0 +1,263 @@
+/*******************************************************************************
+ * Copyright (c) 2025 IBM Corporation and others.
+ *
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * IBM Corporation - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.jdt.internal.ui.javaeditor;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeSet;
+import java.util.WeakHashMap;
+
+import org.eclipse.jface.text.BadLocationException;
+import org.eclipse.jface.text.DocumentEvent;
+import org.eclipse.jface.text.IDocument;
+import org.eclipse.jface.text.IDocumentListener;
+import org.eclipse.jface.text.IRegion;
+import org.eclipse.jface.text.Region;
+
+/**
+ * Tracks dirty (modified) lines in a document to support "format edited lines" functionality.
+ * This class maintains a set of line numbers that have been modified since the last format/save,
+ * automatically adjusting them when lines are inserted or deleted to prevent race conditions.
+ *
+ * @since 3.40
+ */
+public class DocumentDirtyTracker implements IDocumentListener {
+
+ /** Synchronized map to associate trackers with documents without requiring API changes */
+ private static final Map trackers = Collections.synchronizedMap(new WeakHashMap<>());
+
+ /** Set of dirty line numbers, maintained in sorted order */
+ private final TreeSet dirtyLines = new TreeSet<>();
+
+ /** The document being tracked */
+ private final IDocument document;
+
+ /**
+ * Gets or creates a DocumentDirtyTracker for the given document.
+ *
+ * @param document the document to track
+ * @return the tracker for this document
+ */
+ public static DocumentDirtyTracker get(IDocument document) {
+ if (document == null) {
+ throw new IllegalArgumentException("Document cannot be null"); //$NON-NLS-1$
+ }
+ return trackers.computeIfAbsent(document, DocumentDirtyTracker::new);
+ }
+
+ /**
+ * Private constructor - use {@link #get(IDocument)} instead.
+ *
+ * @param document the document to track
+ */
+ private DocumentDirtyTracker(IDocument document) {
+ this.document = document;
+ document.addDocumentListener(this);
+ }
+
+ /**
+ * Returns the dirty regions (ranges of consecutive dirty lines).
+ * This method is safe to call concurrently and returns regions that are
+ * always valid for the current document state.
+ *
+ * @return array of regions representing dirty lines, or null if no lines are dirty
+ */
+ public synchronized IRegion[] getDirtyRegions() {
+ if (dirtyLines.isEmpty()) {
+ return null;
+ }
+
+ List regions = new ArrayList<>();
+ Integer startLine = null;
+ Integer previousLine = null;
+
+ for (Integer line : dirtyLines) {
+ if (startLine == null) {
+ // Start of a new region
+ startLine = line;
+ previousLine = line;
+ } else if (line == previousLine + 1) {
+ // Consecutive line - extend the current region
+ previousLine = line;
+ } else {
+ // Gap found - close current region and start a new one
+ try {
+ regions.add(createRegion(startLine, previousLine));
+ } catch (BadLocationException e) {
+ // Line was deleted or invalid - skip this region
+ }
+ startLine = line;
+ previousLine = line;
+ }
+ }
+
+ // Add the last region
+ if (startLine != null) {
+ try {
+ regions.add(createRegion(startLine, previousLine));
+ } catch (BadLocationException e) {
+ // Line was deleted or invalid - skip this region
+ }
+ }
+
+ return regions.isEmpty() ? null : regions.toArray(new IRegion[regions.size()]);
+ }
+
+ /**
+ * Creates a region from a range of line numbers.
+ *
+ * @param startLine the first line (inclusive)
+ * @param endLine the last line (inclusive)
+ * @return the region covering these lines
+ * @throws BadLocationException if the lines are invalid
+ */
+ private IRegion createRegion(int startLine, int endLine) throws BadLocationException {
+ IRegion startLineInfo = document.getLineInformation(startLine);
+ IRegion endLineInfo = document.getLineInformation(endLine);
+
+ int offset = startLineInfo.getOffset();
+ int length = endLineInfo.getOffset() + endLineInfo.getLength() - offset;
+
+ return new Region(offset, length);
+ }
+
+ /**
+ * Clears all dirty line markers. Should be called after successful formatting.
+ */
+ public synchronized void clearDirtyLines() {
+ dirtyLines.clear();
+ }
+
+ /**
+ * Marks specific lines as dirty.
+ *
+ * @param lines the line numbers to mark as dirty
+ */
+ public synchronized void markLinesDirty(int... lines) {
+ for (int line : lines) {
+ if (line >= 0) {
+ dirtyLines.add(line);
+ }
+ }
+ }
+
+ /**
+ * Stores the state before a change to calculate removed lines correctly.
+ * Note: This assumes document changes are serialized (which is guaranteed by IDocument contract).
+ * The Eclipse document model ensures documentAboutToBeChanged and documentChanged are called
+ * sequentially for each change, never concurrently.
+ */
+ private int lineCountBeforeChange = -1;
+
+ @Override
+ public synchronized void documentAboutToBeChanged(DocumentEvent event) {
+ // Store the number of lines that will be removed before the change is applied
+ try {
+ int offset = event.getOffset();
+ int length = event.getLength();
+ if (length > 0) {
+ int startLine = document.getLineOfOffset(offset);
+ int endLine = document.getLineOfOffset(offset + length);
+ lineCountBeforeChange = endLine - startLine;
+ } else {
+ lineCountBeforeChange = 0;
+ }
+ } catch (BadLocationException e) {
+ lineCountBeforeChange = 0;
+ }
+ }
+
+ @Override
+ public synchronized void documentChanged(DocumentEvent event) {
+ try {
+ // Get the line numbers affected by this change
+ int offset = event.getOffset();
+ String text = event.getText();
+
+ int startLine = document.getLineOfOffset(offset);
+ int linesAdded = text != null ? countLines(text) : 0;
+ int linesRemoved = lineCountBeforeChange >= 0 ? lineCountBeforeChange : 0;
+
+ // Mark the start line as dirty (where the edit occurred)
+ dirtyLines.add(startLine);
+ // If newlines were added, also mark the end line as dirty
+ if (linesAdded > 0) {
+ dirtyLines.add(startLine + linesAdded);
+ }
+
+ // Adjust line numbers if lines were added or removed
+ int netChange = linesAdded - linesRemoved;
+ if (netChange != 0) {
+ adjustLineNumbers(startLine + 1, netChange);
+ }
+
+ lineCountBeforeChange = -1; // Reset for next change
+
+ } catch (BadLocationException e) {
+ // If we can't determine the line, ignore this change
+ lineCountBeforeChange = -1;
+ }
+ }
+
+ /**
+ * Adjusts line numbers after a given line when lines are inserted or deleted.
+ *
+ * @param afterLine the line after which to adjust
+ * @param delta the number of lines added (positive) or removed (negative)
+ */
+ private void adjustLineNumbers(int afterLine, int delta) {
+ // Get all lines after the change point
+ TreeSet linesToAdjust = new TreeSet<>(dirtyLines.tailSet(afterLine));
+
+ // Remove and re-add with adjusted line numbers
+ dirtyLines.removeAll(linesToAdjust);
+ for (Integer line : linesToAdjust) {
+ int newLine = line + delta;
+ if (newLine >= 0) {
+ dirtyLines.add(newLine);
+ }
+ }
+ }
+
+ /**
+ * Counts the number of newline characters in a string.
+ *
+ * @param text the text to analyze
+ * @return the number of newlines (0 if text is null or empty)
+ */
+ private int countLines(String text) {
+ if (text == null || text.isEmpty()) {
+ return 0;
+ }
+
+ int count = 0;
+ for (int i = 0; i < text.length(); i++) {
+ if (text.charAt(i) == '\n') {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Removes the tracker from the document (cleanup).
+ * Should be called when the document is no longer needed.
+ */
+ public void dispose() {
+ document.removeDocumentListener(this);
+ trackers.remove(document);
+ }
+}
diff --git a/pom.xml b/pom.xml
index 9d96bff4a92..88952d69236 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,7 +16,7 @@
org.eclipse
eclipse-platform-parent
4.39.0-SNAPSHOT
- ../eclipse-platform-parent
+
eclipse.jdt.ui
@@ -25,7 +25,7 @@
scm:git:https://github.com/eclipse-jdt/eclipse.jdt.ui.git
-
+ 5.0.1