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..9d5734cb9a9 --- /dev/null +++ b/org.eclipse.jdt.text.tests/src/org/eclipse/jdt/text/tests/DocumentDirtyTrackerTest.java @@ -0,0 +1,311 @@ +/******************************************************************************* + * Copyright (c) 2025 Carsten Hammer 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: + * Carsten Hammer - initial API and implementation (with assistance from GitHub Copilot) + *******************************************************************************/ +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"); + } + + @Test + public void testIncrementalSingleLineEdit() throws Exception { + document.set("line1\nline2\nline3\n"); + tracker.clearDirtyLines(); + + // Incremental edit on line 0: replace "line1" with "modified1" + document.replace(0, 5, "modified1"); + + IRegion[] regions = tracker.getDirtyRegions(); + assertNotNull(regions, "Should have dirty regions after incremental edit"); + assertEquals(1, regions.length, "Should have 1 dirty region"); + } + + @Test + public void testIncrementalInsertNewLine() throws Exception { + document.set("line1\nline2\nline3\n"); + tracker.clearDirtyLines(); + + // Insert a new line after "line1\n" (offset 6) + document.replace(6, 0, "inserted\n"); + + IRegion[] regions = tracker.getDirtyRegions(); + assertNotNull(regions, "Should have dirty regions after line insertion"); + } + + @Test + public void testIncrementalDeleteLine() throws Exception { + document.set("line1\nline2\nline3\n"); + tracker.clearDirtyLines(); + + // Mark line 2 as dirty first + tracker.markLinesDirty(2); + + // Delete "line2\n" (offset 6, length 6) + document.replace(6, 6, ""); + + IRegion[] regions = tracker.getDirtyRegions(); + assertNotNull(regions, "Should still have dirty regions after deletion"); + } + + @Test + public void testIncrementalMultipleEditsOnDifferentLines() throws Exception { + document.set("line1\nline2\nline3\nline4\n"); + tracker.clearDirtyLines(); + + // Edit line 0 + document.replace(0, 5, "mod1"); + // Edit line 2 (offsets shifted because line 0 is now shorter) + int line2Offset = document.getLineOffset(2); + document.replace(line2Offset, 5, "mod3"); + + IRegion[] regions = tracker.getDirtyRegions(); + assertNotNull(regions, "Should have dirty regions"); + assertEquals(2, regions.length, "Should have 2 separate dirty regions for non-consecutive edits"); + } +} 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..6627f4141fd 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; @@ -326,10 +327,28 @@ 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; + IDocument document = null; + DocumentDirtyTracker tracker = null; + if (needsChangedRegions) { + document = getDocument(unit); + if (document != null) { + 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 +398,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 +425,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 +439,11 @@ 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 && tracker != null) { + tracker.clearDirtyLines(); + } if (undoEdits.size() > 0) { UndoEdit[] undoEditArray= undoEdits.toArray(new UndoEdit[undoEdits.size()]); @@ -655,6 +679,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, new NullProgressMonitor()); + buffer= manager.getTextFileBuffer(path, LocationKind.IFILE); + return buffer != null ? buffer.getDocument() : null; + } finally { + if (buffer != null) + manager.disconnect(path, LocationKind.IFILE, new NullProgressMonitor()); + } + } + + /** + * 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..6d4f11e4e1d --- /dev/null +++ b/org.eclipse.jdt.ui/ui/org/eclipse/jdt/internal/ui/javaeditor/DocumentDirtyTracker.java @@ -0,0 +1,263 @@ +/******************************************************************************* + * Copyright (c) 2025 Carsten Hammer 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: + * Carsten Hammer - initial API and implementation (with assistance from GitHub Copilot) + *******************************************************************************/ +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); + } +}