From 0837c6ad2c397ba4ff42adc5a1ffb860c781f37c Mon Sep 17 00:00:00 2001 From: rakasha681 Date: Tue, 21 Apr 2026 19:58:47 -0400 Subject: [PATCH 01/10] Implement code folding feature in the editor --- com/repdev/EditorComposite.java | 180 +++++++++---- com/repdev/FoldingManager.java | 443 ++++++++++++++++++++++++++++++++ com/repdev/InputShell.java | 3 +- com/repdev/MainShell.java | 4 +- 4 files changed, 578 insertions(+), 52 deletions(-) create mode 100644 com/repdev/FoldingManager.java diff --git a/com/repdev/EditorComposite.java b/com/repdev/EditorComposite.java index 2a5c7f0..da16d24 100644 --- a/com/repdev/EditorComposite.java +++ b/com/repdev/EditorComposite.java @@ -25,14 +25,10 @@ import java.util.Stack; import org.eclipse.swt.SWT; -import org.eclipse.swt.custom.Bullet; import org.eclipse.swt.custom.CTabFolder; import org.eclipse.swt.custom.CTabItem; import org.eclipse.swt.custom.ExtendedModifyEvent; import org.eclipse.swt.custom.ExtendedModifyListener; -import org.eclipse.swt.custom.LineStyleEvent; -import org.eclipse.swt.custom.LineStyleListener; -import org.eclipse.swt.custom.ST; import org.eclipse.swt.custom.StyleRange; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.dnd.DND; @@ -65,6 +61,7 @@ import org.eclipse.swt.events.VerifyListener; import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.GC; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.graphics.RGB; @@ -86,7 +83,6 @@ import com.repdev.parser.SectionInfo; import com.repdev.parser.Variable; import com.repdev.parser.Token.TokenType; -import org.eclipse.swt.graphics.GlyphMetrics; /** * Main editor for repgen, help, and letter files @@ -134,7 +130,9 @@ public class EditorComposite extends Composite implements TabTextEditorView { private Token startBlockToken; private Token endBlockToken; - + + private FoldingManager folding; + static SuggestShell suggest = new SuggestShell(); private static Font DEFAULT_FONT; @@ -210,6 +208,9 @@ public void undo() { if( !canUndo() ) return; + // Stored undo offsets reference the unfolded buffer; expand first to keep them valid. + if (folding != null && folding.hasActiveFolds()) folding.expandAll(); + try { TextChange change; @@ -262,6 +263,8 @@ public void redo() { if( !canRedo() ) return; + if (folding != null && folding.hasActiveFolds()) folding.expandAll(); + try { TextChange change; @@ -500,13 +503,24 @@ public Color getLineColor(){ return highlighter.getLineColor(); } - //Calculate and expand the width of numbered "bullet" margin. Allows the whole number to be displayed as more lines are added to the file. + /** Width of just the line-number column (no fold column). */ + public final int calcNumberColumnWidth(){ + if (!showLineNumbers) return 0; + int lastLine = txt.getLineCount()+1; + return (Integer.toString(lastLine).length() * 12) + 6; + } + + //Calculate and expand the width of the gutter margin (line numbers + fold column). final public int calcWidth(){ + int foldW = (folding != null) ? folding.getExtraGutterWidth() : 0; if (this.showLineNumbers) { - int lastLine = txt.getLineCount()+1; - return (Integer.toString(lastLine).length() * 12) +6; + return calcNumberColumnWidth() + foldW; } - return 12; //return a width of 12px for "right click" implementation... eventually. + return 12 + foldW; + } + + public FoldingManager getFolding(){ + return folding; } private void buildGUI() { setLayout(new FormLayout()); @@ -541,9 +555,16 @@ private void buildGUI() { // Load the Section Info sec = new BackgroundSectionParser(parser.getLtokens(),txt.getText()); + // Code folding - only meaningful for RepGen (parser provides head/end tokens) + if (file.getType() == FileType.REPGEN) { + folding = new FoldingManager(this, txt, parser); + } + txt.addDisposeListener(new DisposeListener(){ public void widgetDisposed(DisposeEvent e) { + if( folding != null) + folding.dispose(); if( parser != null) parser.cleanupTokenCache(); } @@ -587,6 +608,17 @@ public void keyTraversed(TraverseEvent e) { //Place any auto complete things in here txt.addVerifyListener(new VerifyListener() { public void verifyText(VerifyEvent e) { + // If an edit would span the header line of a folded region, + // expand that region first so the hidden text can't be orphaned. + if (folding != null && !folding.isInFoldOp() && folding.hasActiveFolds()) { + int startLine = txt.getLineAtOffset(e.start); + int endLine = txt.getLineAtOffset(e.end); + boolean needsExpand = false; + for (int ln = startLine; ln <= endLine; ln++) { + if (folding.foldedAtLine(ln) != null) { needsExpand = true; break; } + } + if (needsExpand) folding.expandAll(); + } if (e.text.equals("\t")) { if(snippetMode){ @@ -638,42 +670,42 @@ public void verifyText(VerifyEvent e) { } }); - // Set the style of numbered bullets (12 pixels wide for each digit) - final StyleRange style = new StyleRange(); - final int bulletStyle; - int bulletWidth = 12; - if (showLineNumbers){ - bulletWidth = calcWidth(); - bulletStyle = ST.BULLET_NUMBER; - } else{ - bulletStyle = ST.BULLET_TEXT; //another eventual implementation for "right click" feature + // Reserve left-margin space for the gutter; numbers and fold triangles + // are painted directly by the paint listeners below. + if (showLineNumbers || folding != null) { + txt.setMargins(calcWidth(), 0, 0, 0); } - style.foreground = highlighter.getBulletColor(); - style.start = 1; - style.length = txt.getLineCount(); - style.metrics = new GlyphMetrics(0, 0, bulletWidth); - - // Add the style (numbered bullets) to the text in file. - txt.addLineStyleListener(new LineStyleListener() { - public void lineGetStyle(LineStyleEvent e) { - e.bulletIndex = txt.getLineAtOffset(e.lineOffset); - if (showLineNumbers){ - style.metrics.width = calcWidth(); - e.bullet = new Bullet(bulletStyle, style); - } - } - }); - - // Add paint listener to modify numbered bullets, when lines are being added + + // Paint the line numbers in the left portion of the gutter. The fold + // triangle painter (FoldingManager) draws in the right portion, so the + // two never overlap (VS Code-style [numbers][fold][text] layout). + final Color bulletColor = highlighter.getBulletColor(); txt.addPaintListener(new PaintListener (){ public void paintControl (PaintEvent e){ - - Rectangle clientArea = txt.getClientArea(); - // To minimize the amount of page being redrawn, trying to limit it to just the numbered bullet margin area - int width = calcWidth(); - Rectangle bgArea = new Rectangle(-2,-2,width,(txt.getLineCount()+1) * txt.getLineHeight()); - - txt.getLineCount(); + if (!showLineNumbers) return; + int lh = txt.getLineHeight(); + if (lh == 0) return; + int topLine = txt.getTopIndex(); + int clientH = txt.getClientArea().height; + int maxLine = topLine + (clientH / lh) + 2; + if (maxLine > txt.getLineCount()) maxLine = txt.getLineCount(); + + int numberColW = calcNumberColumnWidth(); + GC gc = e.gc; + Color oldFg = gc.getForeground(); + gc.setForeground(bulletColor); + + for (int line = topLine; line < maxLine; line++) { + int y; + try { y = txt.getLocationAtOffset(txt.getOffsetAtLine(line)).y; } + catch (IllegalArgumentException ex) { continue; } + String num = String.valueOf(line + 1); + int numW = gc.textExtent(num).x; + int nx = numberColW - numW - 4; + if (nx < 0) nx = 0; + gc.drawString(num, nx, y, true); + } + gc.setForeground(oldFg); } }); @@ -682,6 +714,28 @@ public void paintControl (PaintEvent e){ public void modifyText(ExtendedModifyEvent event) { lineHighlight(); + // Keep folded region line numbers in sync with edits above them. + // Only needed for user edits; fold/unfold operations manage their own shifts. + if (folding != null && !folding.isInFoldOp() && folding.hasActiveFolds()) { + String inserted = ""; + try { + if (event.length > 0) + inserted = txt.getText(event.start, event.start + event.length - 1); + } catch (Exception ex) { inserted = ""; } + int removedNL = 0, addedNL = 0; + String removed = event.replacedText == null ? "" : event.replacedText; + for (int i = 0; i < removed.length(); i++) if (removed.charAt(i) == '\n') removedNL++; + for (int i = 0; i < inserted.length(); i++) if (inserted.charAt(i) == '\n') addedNL++; + int delta = addedNL - removedNL; + if (delta != 0) { + int editStartLine = txt.getLineAtOffset(event.start); + folding.shiftFoldsBelow(editStartLine, delta); + } + } + if (folding != null && !folding.isInFoldOp()) { + folding.recomputeRanges(); + } + modified = true; updateModified(); @@ -692,7 +746,11 @@ public void modifyText(ExtendedModifyEvent event) { else if (undoMode == 2) stack = redos; - if (undoMode != 0 ) { + // Fold/unfold operations mutate the buffer but must not pollute the + // undo history - they have their own separate state. + boolean skipUndoPush = (folding != null && folding.isInFoldOp()); + + if (undoMode != 0 && !skipUndoPush ) { stack.push(new TextChange(event.start, event.length, event.replacedText, txt.getTopIndex())); if (stack.size() > UNDO_LIMIT) @@ -766,11 +824,12 @@ else if(!var.isEdited()){ updateSnippet(); } - // Redraw numbered bullets area - if (showLineNumbers){ - style.metrics.width = calcWidth(); - txt.setStyleRange(style); - txt.redraw(1,1, 72, txt.getLineHeight()*txt.getLineCount(), true); + // Refresh gutter: width may have grown with line count; re-reserve + // the left margin and repaint so the number/triangle columns update. + if (showLineNumbers || folding != null) { + int w = calcWidth(); + txt.setMargins(w, 0, 0, 0); + txt.redraw(0, 0, w, txt.getLineHeight() * txt.getLineCount(), true); } } @@ -847,6 +906,15 @@ public void keyPressed(KeyEvent e) { case 'G': gotoSectionShell(); break; + case '-': + case SWT.KEYPAD_SUBTRACT: + if (folding != null) folding.collapseAtCaret(); + break; + case '=': + case '+': + case SWT.KEYPAD_ADD: + if (folding != null) folding.expandAtCaret(); + break; } } else if( e.stateMask == (SWT.CTRL | SWT.SHIFT) ) { @@ -901,6 +969,15 @@ else if( e.stateMask == (SWT.CTRL | SWT.SHIFT) ) { case 'G': gotoDefinition(); break; + case '-': + case SWT.KEYPAD_SUBTRACT: + if (folding != null) folding.collapseAll(); + break; + case '=': + case '+': + case SWT.KEYPAD_ADD: + if (folding != null) folding.expandAll(); + break; } } else if(e.stateMask == (SWT.ALT)){ @@ -1637,7 +1714,8 @@ else if(tok != null && tok.getBefore() != null){ * @param errorCheck Flag to check errors with symitar */ public void saveFile( boolean errorCheck ){ - file.saveFile(txt.getText()); + String toSave = (folding != null) ? folding.getUnfoldedText() : txt.getText(); + file.saveFile(toSave); commitUndo(); modified = false; updateModified(); @@ -2012,6 +2090,8 @@ public void surroundWithShell() { } public void sendToFormatter(){ + // Formatter does a full-buffer replace; expand all folds first so hidden lines aren't lost. + if (folding != null && folding.hasActiveFolds()) folding.expandAll(); Formatter formatter = new Formatter(txt.getText(),parser.getLtokens()); parser.setReparse(false); txt.setText(formatter.getFormattedFile()); diff --git a/com/repdev/FoldingManager.java b/com/repdev/FoldingManager.java new file mode 100644 index 0000000..44aa895 --- /dev/null +++ b/com/repdev/FoldingManager.java @@ -0,0 +1,443 @@ +/** + * RepDev - RepGen IDE for Symitar + * Copyright (C) 2007 Jake Poznanski, Ryan Schultz, Sean Delaney + * http://repdev.org/ + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package com.repdev; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Stack; + +import org.eclipse.swt.custom.StyledText; +import org.eclipse.swt.events.MouseAdapter; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.events.PaintEvent; +import org.eclipse.swt.events.PaintListener; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.RGB; + +import com.repdev.parser.RepgenParser; +import com.repdev.parser.Token; + +/** + * Code folding for the RepDev editor. Foldable regions come from the RepGen + * parser's existing block matcher: every head/end pair (DEFINE..END, SETUP..END, + * DO..END, PROCEDURE..END, HEADERS..END, bracket comments [...], etc.) that + * spans more than one line can be folded. + * + * Folding is implemented by physically removing the hidden lines from the + * StyledText buffer and caching them in-memory. On save, getUnfoldedText() + * reassembles the full source. + */ +public class FoldingManager { + + /** Width in pixels of the gutter column that holds the fold triangle. */ + public static final int FOLD_COLUMN_WIDTH = 18; + + /** A currently-collapsed region. */ + public static class FoldRegion { + public final int headerLine; + public final String hiddenText; + + FoldRegion(int headerLine, String hiddenText) { + this.headerLine = headerLine; + this.hiddenText = hiddenText; + } + } + + /** A precomputed head/end pair in the current buffer that can be collapsed. */ + public static class FoldableRange { + public final int headerLine; + public final int endLine; + public final int endTokenOffset; + public final boolean bracket; + + FoldableRange(int headerLine, int endLine, int endTokenOffset, boolean bracket) { + this.headerLine = headerLine; + this.endLine = endLine; + this.endTokenOffset = endTokenOffset; + this.bracket = bracket; + } + } + + private final StyledText txt; + private final RepgenParser parser; + private final EditorComposite editor; + + private final ArrayList folded = new ArrayList(); + private final ArrayList foldable = new ArrayList(); + + private final Color markerColor; + private boolean inFoldOp = false; + + public FoldingManager(EditorComposite editor, StyledText txt, RepgenParser parser) { + this.editor = editor; + this.txt = txt; + this.parser = parser; + this.markerColor = new Color(txt.getDisplay(), new RGB(90, 90, 90)); + install(); + } + + private void install() { + txt.addPaintListener(new PaintListener() { + public void paintControl(PaintEvent e) { + paintMarkers(e); + } + }); + + txt.addMouseListener(new MouseAdapter() { + public void mouseDown(MouseEvent e) { + if (e.button != 1 || txt.getLineHeight() == 0) return; + int gutter = editor.calcWidth(); + int foldColStart = gutter - FOLD_COLUMN_WIDTH; + if (foldColStart < 0) foldColStart = 0; + if (e.x < foldColStart || e.x > gutter) return; + int line = txt.getTopIndex() + (e.y / txt.getLineHeight()); + if (line < 0 || line >= txt.getLineCount()) return; + toggleAtLine(line); + } + }); + } + + public int getExtraGutterWidth() { return FOLD_COLUMN_WIDTH; } + + public boolean isInFoldOp() { return inFoldOp; } + + public boolean hasActiveFolds() { return !folded.isEmpty(); } + + /** + * Shift the headerLine of every fold whose header is strictly below the + * given edit line. Called after a user edit changes the newline count above + * folded regions, so they keep pointing at the right buffer lines. + */ + public void shiftFoldsBelow(int editLine, int delta) { + if (delta == 0) return; + for (int i = 0; i < folded.size(); i++) { + FoldRegion fr = folded.get(i); + if (fr.headerLine > editLine) { + folded.set(i, new FoldRegion(fr.headerLine + delta, fr.hiddenText)); + } + } + } + + /** + * Recompute foldable ranges from the current token list. Must be called + * after the parser has caught up with the current buffer state. + */ + public void recomputeRanges() { + foldable.clear(); + if (parser == null) return; + ArrayList tokens = parser.getLtokens(); + if (tokens == null || tokens.isEmpty()) { txt.redraw(); return; } + + // Head tokens on folded header lines are orphans: their matching end + // has been removed from the buffer, so pushing them onto the stack + // would mispair outer regions. Collect those lines up front and skip. + HashSet foldedHeaderLines = new HashSet(); + for (int i = 0; i < folded.size(); i++) foldedHeaderLines.add(folded.get(i).headerLine); + + Stack stack = new Stack(); + int charCount = txt.getCharCount(); + for (int i = 0; i < tokens.size(); i++) { + Token t = tokens.get(i); + String s = t.getStr(); + // Only consider block-level head/end pairs: skip string/date/paren openers + // which produce noisy single-line folds. + if (t.isRealHead() && !"\"".equals(s) && !"'".equals(s) + && !"(".equals(s) && !":(".equals(s)) { + int tStart = t.getStart(); + if (tStart < 0 || tStart >= charCount) continue; + int tLine; + try { tLine = txt.getLineAtOffset(tStart); } + catch (IllegalArgumentException ex) { continue; } + if (foldedHeaderLines.contains(tLine)) continue; // orphan head inside a collapsed region + stack.push(i); + } else if (t.isRealEnd() && !")".equals(s) && !"\"".equals(s) && !"'".equals(s)) { + if (!stack.isEmpty()) { + int headIdx = stack.pop(); + Token head = tokens.get(headIdx); + int hStart = head.getStart(); + int eStart = t.getStart(); + if (hStart < 0 || eStart < 0 || hStart >= charCount || eStart > charCount) continue; + try { + int hl = txt.getLineAtOffset(hStart); + int el = txt.getLineAtOffset(eStart); + boolean isBracket = "[".equals(head.getStr()); + if (el - hl >= 1) foldable.add(new FoldableRange(hl, el, eStart, isBracket)); + } catch (IllegalArgumentException ignored) { } + } + } + } + txt.redraw(); + } + + public FoldableRange foldableAtLine(int line) { + for (int i = 0; i < foldable.size(); i++) { + FoldableRange r = foldable.get(i); + if (r.headerLine == line) return r; + } + return null; + } + + public FoldRegion foldedAtLine(int line) { + for (int i = 0; i < folded.size(); i++) { + FoldRegion r = folded.get(i); + if (r.headerLine == line) return r; + } + return null; + } + + public boolean isFoldableLine(int line) { + return foldableAtLine(line) != null || foldedAtLine(line) != null; + } + + /** Toggle the fold at the given line; returns true if it's now collapsed. */ + public boolean toggleAtLine(int line) { + FoldRegion existing = foldedAtLine(line); + if (existing != null) { + expand(existing); + return false; + } + FoldableRange fr = foldableAtLine(line); + if (fr == null) return false; + collapse(fr); + return true; + } + + /** Collapse the innermost foldable region that contains the caret. */ + public void collapseAtCaret() { + int caretLine = txt.getLineAtOffset(txt.getCaretOffset()); + FoldableRange best = null; + for (int i = 0; i < foldable.size(); i++) { + FoldableRange r = foldable.get(i); + if (r.headerLine <= caretLine && caretLine <= r.endLine) { + if (best == null || r.headerLine > best.headerLine) best = r; + } + } + if (best != null && foldedAtLine(best.headerLine) == null) collapse(best); + } + + public void expandAtCaret() { + int line = txt.getLineAtOffset(txt.getCaretOffset()); + FoldRegion fr = foldedAtLine(line); + if (fr != null) expand(fr); + } + + public void collapseAll() { + // Fold from bottom up so earlier line numbers stay stable during iteration. + ArrayList ranges = new ArrayList(foldable); + Collections.sort(ranges, new Comparator() { + public int compare(FoldableRange a, FoldableRange b) { return b.headerLine - a.headerLine; } + }); + for (int i = 0; i < ranges.size(); i++) { + FoldableRange r = ranges.get(i); + FoldableRange fresh = foldableAtLine(r.headerLine); + if (fresh != null && foldedAtLine(fresh.headerLine) == null) collapse(fresh); + } + } + + public void expandAll() { + // Unfold innermost-first (largest line number first). + ArrayList regions = new ArrayList(folded); + Collections.sort(regions, new Comparator() { + public int compare(FoldRegion a, FoldRegion b) { return b.headerLine - a.headerLine; } + }); + for (int i = 0; i < regions.size(); i++) expand(regions.get(i)); + } + + private void collapse(FoldableRange range) { + // Expand any nested folds inside this range so the captured hidden text is complete. + ArrayList nested = new ArrayList(); + for (int i = 0; i < folded.size(); i++) { + FoldRegion fr = folded.get(i); + if (fr.headerLine > range.headerLine && fr.headerLine <= range.endLine) nested.add(fr); + } + Collections.sort(nested, new Comparator() { + public int compare(FoldRegion a, FoldRegion b) { return b.headerLine - a.headerLine; } + }); + for (int i = 0; i < nested.size(); i++) expand(nested.get(i)); + + // Reacquire the range after any line shifts from the nested expansions. + FoldableRange fresh = foldableAtLine(range.headerLine); + if (fresh == null) return; + range = fresh; + + int lineCount = txt.getLineCount(); + if (range.headerLine + 1 >= lineCount) return; + int sliceStart = txt.getOffsetAtLine(range.headerLine + 1); + int sliceEnd; + if (range.bracket) { + // Keep the ']' visible so the comment highlighter still sees the close. + sliceEnd = range.endTokenOffset; + } else if (range.endLine + 1 >= lineCount) { + sliceEnd = txt.getCharCount(); + } else { + sliceEnd = txt.getOffsetAtLine(range.endLine + 1); + } + if (sliceEnd <= sliceStart) return; + + String hidden = txt.getText(sliceStart, sliceEnd - 1); + int linesBefore = txt.getLineCount(); + + inFoldOp = true; + try { + if (parser != null) parser.setReparse(false); + txt.setRedraw(false); + txt.replaceTextRange(sliceStart, sliceEnd - sliceStart, ""); + } finally { + txt.setRedraw(true); + if (parser != null) { + parser.setReparse(true); + parser.reparseAll(); + } + inFoldOp = false; + } + + int removedLines = linesBefore - txt.getLineCount(); + // Compute the original last visible line that moved up by removedLines. + // After collapse: the "]" (bracket case) or nothing extra stays at headerLine+1. + int breakLine = range.bracket ? range.headerLine : range.endLine; + for (int i = 0; i < folded.size(); i++) { + FoldRegion fr = folded.get(i); + if (fr.headerLine > breakLine) { + folded.set(i, new FoldRegion(fr.headerLine - removedLines, fr.hiddenText)); + } + } + folded.add(new FoldRegion(range.headerLine, hidden)); + + recomputeRanges(); + } + + private void expand(FoldRegion region) { + int lineCount = txt.getLineCount(); + int insertOffset = (region.headerLine + 1 >= lineCount) + ? txt.getCharCount() + : txt.getOffsetAtLine(region.headerLine + 1); + + inFoldOp = true; + try { + if (parser != null) parser.setReparse(false); + txt.setRedraw(false); + txt.replaceTextRange(insertOffset, 0, region.hiddenText); + } finally { + txt.setRedraw(true); + if (parser != null) { + parser.setReparse(true); + parser.reparseAll(); + } + inFoldOp = false; + } + + folded.remove(region); + int addedLines = countNewlines(region.hiddenText); + for (int i = 0; i < folded.size(); i++) { + FoldRegion fr = folded.get(i); + if (fr.headerLine > region.headerLine) { + folded.set(i, new FoldRegion(fr.headerLine + addedLines, fr.hiddenText)); + } + } + + recomputeRanges(); + } + + /** + * Reassembles the complete source by re-inserting all hidden regions. Used + * at save time so folded files persist correctly. + */ + public String getUnfoldedText() { + if (folded.isEmpty()) return txt.getText(); + ArrayList sorted = new ArrayList(folded); + // Insert from bottom up so earlier insert offsets stay valid. + Collections.sort(sorted, new Comparator() { + public int compare(FoldRegion a, FoldRegion b) { return b.headerLine - a.headerLine; } + }); + StringBuilder sb = new StringBuilder(txt.getText()); + for (int i = 0; i < sorted.size(); i++) { + FoldRegion fr = sorted.get(i); + int offset = offsetAtLineStart(sb, fr.headerLine + 1); + sb.insert(offset, fr.hiddenText); + } + return sb.toString(); + } + + private static int offsetAtLineStart(CharSequence cs, int line) { + if (line <= 0) return 0; + int found = 0; + for (int i = 0; i < cs.length(); i++) { + if (cs.charAt(i) == '\n') { + found++; + if (found == line) return i + 1; + } + } + return cs.length(); + } + + private static int countNewlines(String s) { + int n = 0; + for (int i = 0; i < s.length(); i++) if (s.charAt(i) == '\n') n++; + return n; + } + + private void paintMarkers(PaintEvent e) { + if (txt.isDisposed() || txt.getLineHeight() == 0) return; + int lh = txt.getLineHeight(); + int topLine = txt.getTopIndex(); + int clientH = txt.getClientArea().height; + int maxLine = topLine + (clientH / lh) + 2; + if (maxLine > txt.getLineCount()) maxLine = txt.getLineCount(); + + int gutterW = editor.calcWidth(); + int cx = gutterW - (FOLD_COLUMN_WIDTH / 2) - 1; + + GC gc = e.gc; + Color oldFg = gc.getForeground(); + Color oldBg = gc.getBackground(); + gc.setForeground(markerColor); + gc.setBackground(markerColor); + + for (int line = topLine; line < maxLine; line++) { + boolean isFolded = foldedAtLine(line) != null; + boolean isFoldable = isFolded || (foldableAtLine(line) != null); + if (!isFoldable) continue; + int y; + try { + y = txt.getLocationAtOffset(txt.getOffsetAtLine(line)).y; + } catch (IllegalArgumentException ex) { continue; } + int cy = y + (lh / 2); + int s = 6; + if (isFolded) { + // Right-pointing triangle (region is collapsed) + int[] pts = { cx - (s - 2), cy - s, cx + (s - 1), cy, cx - (s - 2), cy + s }; + gc.fillPolygon(pts); + } else { + // Down-pointing triangle (region is expanded, click to collapse) + int[] pts = { cx - s, cy - (s - 3), cx + s, cy - (s - 3), cx, cy + (s - 1) }; + gc.fillPolygon(pts); + } + } + gc.setForeground(oldFg); + gc.setBackground(oldBg); + } + + public void dispose() { + if (markerColor != null && !markerColor.isDisposed()) markerColor.dispose(); + } +} diff --git a/com/repdev/InputShell.java b/com/repdev/InputShell.java index 80c8ff3..7f70983 100644 --- a/com/repdev/InputShell.java +++ b/com/repdev/InputShell.java @@ -33,7 +33,8 @@ import org.eclipse.swt.widgets.Shell; import org.eclipse.swt.widgets.Text; -import com.sun.org.apache.xpath.internal.operations.Bool; +// no longer supported in modern java versions, Boolean is provide by jdk +// import com.sun.org.apache.xpath.internal.operations.Bool; public class InputShell { private static InputShell me = new InputShell(); diff --git a/com/repdev/MainShell.java b/com/repdev/MainShell.java index 3c68e36..3b2d112 100644 --- a/com/repdev/MainShell.java +++ b/com/repdev/MainShell.java @@ -119,7 +119,9 @@ import com.repdev.parser.Error; import com.repdev.parser.RepgenParser; import com.repdev.parser.Task; -import com.sun.org.apache.xpath.internal.operations.Bool; + +// no longer supported in modern java versions, Boolean is provide by jdk +//import com.sun.org.apache.xpath.internal.operations.Bool; //import com.sun.xml.internal.ws.util.xml.NodeListIterator; /** From d8a3d4d5a7d5e7f15baa8bbc8639f4b1124a3d79 Mon Sep 17 00:00:00 2001 From: rakasha681 Date: Wed, 22 Apr 2026 20:48:29 -0400 Subject: [PATCH 02/10] (fix) Ctrl+Shift++ now rebuilds the full editor text in one pass from the folded-region state instead of expanding each fold one-by-one while line positions are shifting. (feat) Enhance folding functionality by updating line count calculations and displaying line numbers that account for the folded text --- com/repdev/EditorComposite.java | 5 +-- com/repdev/FoldingManager.java | 58 ++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/com/repdev/EditorComposite.java b/com/repdev/EditorComposite.java index da16d24..d5ce524 100644 --- a/com/repdev/EditorComposite.java +++ b/com/repdev/EditorComposite.java @@ -506,7 +506,7 @@ public Color getLineColor(){ /** Width of just the line-number column (no fold column). */ public final int calcNumberColumnWidth(){ if (!showLineNumbers) return 0; - int lastLine = txt.getLineCount()+1; + int lastLine = (folding != null) ? folding.getUnfoldedLineCount() : txt.getLineCount(); return (Integer.toString(lastLine).length() * 12) + 6; } @@ -699,7 +699,8 @@ public void paintControl (PaintEvent e){ int y; try { y = txt.getLocationAtOffset(txt.getOffsetAtLine(line)).y; } catch (IllegalArgumentException ex) { continue; } - String num = String.valueOf(line + 1); + int displayLine = (folding != null) ? folding.getDisplayLineNumber(line) : line + 1; + String num = String.valueOf(displayLine); int numW = gc.textExtent(num).x; int nx = numberColW - numW - 4; if (nx < 0) nx = 0; diff --git a/com/repdev/FoldingManager.java b/com/repdev/FoldingManager.java index 44aa895..eb6d28a 100644 --- a/com/repdev/FoldingManager.java +++ b/com/repdev/FoldingManager.java @@ -123,6 +123,14 @@ public void mouseDown(MouseEvent e) { public boolean hasActiveFolds() { return !folded.isEmpty(); } + public int getDisplayLineNumber(int visibleLine) { + return displayLineNumberFor(visibleLine, folded); + } + + public int getUnfoldedLineCount() { + return unfoldedLineCount(txt.getLineCount(), folded); + } + /** * Shift the headerLine of every fold whose header is strictly below the * given edit line. Called after a user edit changes the newline count above @@ -255,12 +263,25 @@ public void collapseAll() { } public void expandAll() { - // Unfold innermost-first (largest line number first). - ArrayList regions = new ArrayList(folded); - Collections.sort(regions, new Comparator() { - public int compare(FoldRegion a, FoldRegion b) { return b.headerLine - a.headerLine; } - }); - for (int i = 0; i < regions.size(); i++) expand(regions.get(i)); + if (folded.isEmpty()) return; + String expanded = expandAllText(txt.getText(), folded); + + inFoldOp = true; + try { + if (parser != null) parser.setReparse(false); + txt.setRedraw(false); + txt.replaceTextRange(0, txt.getCharCount(), expanded); + } finally { + txt.setRedraw(true); + if (parser != null) { + parser.setReparse(true); + parser.reparseAll(); + } + inFoldOp = false; + } + + folded.clear(); + recomputeRanges(); } private void collapse(FoldableRange range) { @@ -364,12 +385,16 @@ private void expand(FoldRegion region) { */ public String getUnfoldedText() { if (folded.isEmpty()) return txt.getText(); - ArrayList sorted = new ArrayList(folded); + return expandAllText(txt.getText(), folded); + } + + static String expandAllText(String visibleText, ArrayList foldedRegions) { + ArrayList sorted = new ArrayList(foldedRegions); // Insert from bottom up so earlier insert offsets stay valid. Collections.sort(sorted, new Comparator() { public int compare(FoldRegion a, FoldRegion b) { return b.headerLine - a.headerLine; } }); - StringBuilder sb = new StringBuilder(txt.getText()); + StringBuilder sb = new StringBuilder(visibleText); for (int i = 0; i < sorted.size(); i++) { FoldRegion fr = sorted.get(i); int offset = offsetAtLineStart(sb, fr.headerLine + 1); @@ -396,6 +421,23 @@ private static int countNewlines(String s) { return n; } + static int displayLineNumberFor(int visibleLine, ArrayList foldedRegions) { + int line = visibleLine + 1; + for (int i = 0; i < foldedRegions.size(); i++) { + FoldRegion fr = foldedRegions.get(i); + if (fr.headerLine < visibleLine) line += countNewlines(fr.hiddenText); + } + return line; + } + + static int unfoldedLineCount(int visibleLineCount, ArrayList foldedRegions) { + int lineCount = visibleLineCount; + for (int i = 0; i < foldedRegions.size(); i++) { + lineCount += countNewlines(foldedRegions.get(i).hiddenText); + } + return lineCount; + } + private void paintMarkers(PaintEvent e) { if (txt.isDisposed() || txt.getLineHeight() == 0) return; int lh = txt.getLineHeight(); From 2f50df8c722757caff49e48f441127f28f53312e Mon Sep 17 00:00:00 2001 From: rakasha681 Date: Thu, 23 Apr 2026 20:02:15 -0400 Subject: [PATCH 03/10] (feat) Enhance folding functionality with improved undo/redo support and batch processing --- com/repdev/EditorComposite.java | 125 ++++++++++++++++++---- com/repdev/FoldingManager.java | 183 ++++++++++++++++++++++++++++---- 2 files changed, 269 insertions(+), 39 deletions(-) diff --git a/com/repdev/EditorComposite.java b/com/repdev/EditorComposite.java index d5ce524..a490784 100644 --- a/com/repdev/EditorComposite.java +++ b/com/repdev/EditorComposite.java @@ -149,10 +149,20 @@ public class EditorComposite extends Composite implements TabTextEditorView { DEFAULT_FONT = cur; } + // Fold-operation tags used inside TextChange entries; drive the dispatch in + // undo()/redo() so fold/unfold is a first-class undoable action. + public static final int FOLD_OP_NONE = 0; + public static final int FOLD_OP_COLLAPSE = 1; // a single range was collapsed at foldLine + public static final int FOLD_OP_EXPAND = 2; // a single region was expanded at foldLine + public static final int FOLD_OP_COLLAPSE_ALL = 3; // fold-all (foldLine unused) + public static final int FOLD_OP_EXPAND_ALL = 4; // unfold-all (foldLine unused) + class TextChange { private int start, length, topIndex; private String replacedText; private boolean commit; + private int foldOp = FOLD_OP_NONE; + private int foldLine; public TextChange(boolean commit) { this.commit = commit; @@ -164,6 +174,11 @@ public TextChange(int start, int length, String replacedText, int topIndex) { this.topIndex = topIndex; this.commit = false; } + public TextChange(int foldOp, int foldLine) { + this.foldOp = foldOp; + this.foldLine = foldLine; + this.commit = false; + } public int getTopIndex(){ return topIndex; @@ -184,6 +199,10 @@ public int getLength() { public String getReplacedText() { return replacedText; } + + public boolean isFoldOp() { return foldOp != FOLD_OP_NONE; } + public int getFoldOp() { return foldOp; } + public int getFoldLine() { return foldLine; } } public EditorComposite(Composite parent, CTabItem tabItem, SymitarFile file) { @@ -208,9 +227,6 @@ public void undo() { if( !canUndo() ) return; - // Stored undo offsets reference the unfolded buffer; expand first to keep them valid. - if (folding != null && folding.hasActiveFolds()) folding.expandAll(); - try { TextChange change; @@ -231,9 +247,14 @@ public void undo() { while (!(undos.size() == 0 || (change = undos.pop()).isCommit())) { - txt.replaceTextRange(change.getStart(), change.getLength(), change.getReplacedText()); - txt.setCaretOffset(change.getStart()); - txt.setTopIndex(change.getTopIndex()); + if (change.isFoldOp()) { + applyFoldUndo(change); + redos.push(change); + } else { + txt.replaceTextRange(change.getStart(), change.getLength(), change.getReplacedText()); + txt.setCaretOffset(change.getStart()); + txt.setTopIndex(change.getTopIndex()); + } } @@ -263,8 +284,6 @@ public void redo() { if( !canRedo() ) return; - if (folding != null && folding.hasActiveFolds()) folding.expandAll(); - try { TextChange change; @@ -279,9 +298,14 @@ public void redo() { parser.setReparse(false); while (!(redos.size() == 0 || (change = redos.pop()).isCommit())) { - txt.replaceTextRange(change.getStart(), change.getLength(), change.getReplacedText()); - txt.setCaretOffset(change.getStart()); - txt.setTopIndex(change.getTopIndex()); + if (change.isFoldOp()) { + applyFoldRedo(change); + undos.push(change); + } else { + txt.replaceTextRange(change.getStart(), change.getLength(), change.getReplacedText()); + txt.setCaretOffset(change.getStart()); + txt.setTopIndex(change.getTopIndex()); + } } undos.push(new TextChange(true)); } @@ -310,6 +334,42 @@ public void commitUndo() { undos.add(new TextChange(true)); } + /** + * Record a fold/unfold as its own undoable step. Called by FoldingManager + * after a successful user-initiated fold op. The fold is bracketed with + * commit markers so it becomes an atomic undo unit, independent of any + * text edits before or after. + */ + public void pushFoldUndo(int op, int line) { + if (undoMode != 1) return; // skip during initialisation (0) or replay (2) + commitUndo(); + undos.push(new TextChange(op, line)); + undos.push(new TextChange(true)); + if (undos.size() > UNDO_LIMIT) undos.remove(0); + } + + /** Inverse of the recorded fold op — used when replaying undo. */ + private void applyFoldUndo(TextChange change) { + if (folding == null) return; + switch (change.getFoldOp()) { + case FOLD_OP_COLLAPSE: folding.expandAtLineSilent(change.getFoldLine()); break; + case FOLD_OP_EXPAND: folding.collapseAtLineSilent(change.getFoldLine()); break; + case FOLD_OP_COLLAPSE_ALL: folding.expandAllSilent(); break; + case FOLD_OP_EXPAND_ALL: folding.collapseAllSilent(); break; + } + } + + /** Same op as recorded — used when replaying redo. */ + private void applyFoldRedo(TextChange change) { + if (folding == null) return; + switch (change.getFoldOp()) { + case FOLD_OP_COLLAPSE: folding.collapseAtLineSilent(change.getFoldLine()); break; + case FOLD_OP_EXPAND: folding.expandAtLineSilent(change.getFoldLine()); break; + case FOLD_OP_COLLAPSE_ALL: folding.collapseAllSilent(); break; + case FOLD_OP_EXPAND_ALL: folding.expandAllSilent(); break; + } + } + public void setLineColor(SyntaxHighlighter hiColor){ lineBackgroundColor=hiColor.getLineColor(); blockMatchColor=hiColor.getBlockMatchColor(); @@ -608,8 +668,13 @@ public void keyTraversed(TraverseEvent e) { //Place any auto complete things in here txt.addVerifyListener(new VerifyListener() { public void verifyText(VerifyEvent e) { - // If an edit would span the header line of a folded region, - // expand that region first so the hidden text can't be orphaned. + // If an edit would span the header line of a folded region, the + // hidden text must be reinstated before the edit lands. SWT + // explicitly forbids mutating the widget from inside verifyText + // (the pending edit's offsets become stale the moment we run + // replaceTextRange), so we cancel this keystroke and schedule + // the expand for the next UI tick. The user can re-issue the + // edit against the now-expanded buffer. if (folding != null && !folding.isInFoldOp() && folding.hasActiveFolds()) { int startLine = txt.getLineAtOffset(e.start); int endLine = txt.getLineAtOffset(e.end); @@ -617,7 +682,17 @@ public void verifyText(VerifyEvent e) { for (int ln = startLine; ln <= endLine; ln++) { if (folding.foldedAtLine(ln) != null) { needsExpand = true; break; } } - if (needsExpand) folding.expandAll(); + if (needsExpand) { + e.doit = false; + txt.getDisplay().asyncExec(new Runnable() { + public void run() { + if (!txt.isDisposed() && folding != null && folding.hasActiveFolds()) { + folding.expandAll(); + } + } + }); + return; + } } if (e.text.equals("\t")) { @@ -719,10 +794,24 @@ public void modifyText(ExtendedModifyEvent event) { // Only needed for user edits; fold/unfold operations manage their own shifts. if (folding != null && !folding.isInFoldOp() && folding.hasActiveFolds()) { String inserted = ""; - try { - if (event.length > 0) - inserted = txt.getText(event.start, event.start + event.length - 1); - } catch (Exception ex) { inserted = ""; } + // Bounds-check before reading the inserted slice — silently + // catching here would miscount addedNL and leave folds with + // wrong headerLines, which later drops them at EOF (see + // FoldingManager.offsetAtLineStart diagnostic). + int charCount = txt.getCharCount(); + if (event.length > 0 && event.start >= 0 && event.start + event.length <= charCount) { + try { + inserted = txt.getTextRange(event.start, event.length); + } catch (Exception ex) { + System.err.println("EditorComposite.modifyText: getTextRange failed — start=" + + event.start + " length=" + event.length + " charCount=" + charCount + + "; fold shifts may be wrong: " + ex); + } + } else if (event.length > 0) { + System.err.println("EditorComposite.modifyText: event offsets out of range — start=" + + event.start + " length=" + event.length + " charCount=" + charCount + + "; fold shifts may be wrong"); + } int removedNL = 0, addedNL = 0; String removed = event.replacedText == null ? "" : event.replacedText; for (int i = 0; i < removed.length(); i++) if (removed.charAt(i) == '\n') removedNL++; diff --git a/com/repdev/FoldingManager.java b/com/repdev/FoldingManager.java index eb6d28a..8260e5d 100644 --- a/com/repdev/FoldingManager.java +++ b/com/repdev/FoldingManager.java @@ -56,10 +56,23 @@ public class FoldingManager { public static class FoldRegion { public final int headerLine; public final String hiddenText; + /** + * The batch-t=0 headerLine for this fold — preserved through shifts. + * During a fold-all batch, {@link #headerLine} drifts as siblings + * collapse above this entry, but originalHeaderLine stays anchored so + * nested-check math stays in a consistent coord system. + * Outside a batch this equals {@link #headerLine}. + */ + public final int originalHeaderLine; FoldRegion(int headerLine, String hiddenText) { + this(headerLine, hiddenText, headerLine); + } + + FoldRegion(int headerLine, String hiddenText, int originalHeaderLine) { this.headerLine = headerLine; this.hiddenText = hiddenText; + this.originalHeaderLine = originalHeaderLine; } } @@ -87,6 +100,17 @@ public static class FoldableRange { private final Color markerColor; private boolean inFoldOp = false; + // True while a fold-all is iterating. Individual collapse/expand ops suppress + // their per-op parser.reparseAll() + recomputeRanges() (which triggers the + // "Parsing vars for ..." pass and is O(N) per fold). One reparse runs at + // the end of the batch, covering all collapses at once. + private boolean batchMode = false; + // Snapshot of hiddenText strings present in `folded` at batch start. Used + // in collapseInternal to distinguish nested folds that existed before the + // batch (user pre-folds — their lines are NOT reflected in the snapshot + // range's endLine) from those the batch itself created (whose lines ARE in + // the snapshot). Lets us delta-adjust the range without a mid-op reparse. + private HashSet preBatchHiddenTexts = null; public FoldingManager(EditorComposite editor, StyledText txt, RepgenParser parser) { this.editor = editor; @@ -141,7 +165,7 @@ public void shiftFoldsBelow(int editLine, int delta) { for (int i = 0; i < folded.size(); i++) { FoldRegion fr = folded.get(i); if (fr.headerLine > editLine) { - folded.set(i, new FoldRegion(fr.headerLine + delta, fr.hiddenText)); + folded.set(i, new FoldRegion(fr.headerLine + delta, fr.hiddenText, fr.originalHeaderLine)); } } } @@ -249,20 +273,60 @@ public void expandAtCaret() { if (fr != null) expand(fr); } - public void collapseAll() { + public void collapseAll() { collapseAllInternal(true); } + + /** Undo-replay entry point — collapses without pushing a new undo marker. */ + public void collapseAllSilent() { collapseAllInternal(false); } + + private void collapseAllInternal(boolean pushUndo) { // Fold from bottom up so earlier line numbers stay stable during iteration. ArrayList ranges = new ArrayList(foldable); Collections.sort(ranges, new Comparator() { public int compare(FoldableRange a, FoldableRange b) { return b.headerLine - a.headerLine; } }); - for (int i = 0; i < ranges.size(); i++) { - FoldableRange r = ranges.get(i); - FoldableRange fresh = foldableAtLine(r.headerLine); - if (fresh != null && foldedAtLine(fresh.headerLine) == null) collapse(fresh); + boolean prevBatch = batchMode; + batchMode = true; + HashSet prevSnapshot = preBatchHiddenTexts; + preBatchHiddenTexts = new HashSet(); + for (int i = 0; i < folded.size(); i++) preBatchHiddenTexts.add(folded.get(i).hiddenText); + // Anchor the t=0 coord system for this batch: every fold already in + // `folded` gets originalHeaderLine = its current headerLine. Batch- + // added folds will set originalHeaderLine from their snapshot range's + // headerLine, which is also a t=0 coord. That keeps nested-check math + // consistent regardless of shift accumulation. + for (int i = 0; i < folded.size(); i++) { + FoldRegion fr = folded.get(i); + folded.set(i, new FoldRegion(fr.headerLine, fr.hiddenText, fr.headerLine)); + } + boolean any = false; + try { + for (int i = 0; i < ranges.size(); i++) { + FoldableRange r = ranges.get(i); + FoldableRange fresh = foldableAtLine(r.headerLine); + if (fresh != null && foldedAtLine(fresh.headerLine) == null) { + collapseInternal(fresh, false); + any = true; + } + } + } finally { + batchMode = prevBatch; + preBatchHiddenTexts = prevSnapshot; + } + if (any) { + // One reparse + foldable rebuild to leave the parser and range cache + // consistent after suppressing per-op reparses during the batch. + if (parser != null) parser.reparseAll(); + recomputeRanges(); + if (pushUndo) editor.pushFoldUndo(EditorComposite.FOLD_OP_COLLAPSE_ALL, -1); } } - public void expandAll() { + public void expandAll() { expandAllInternal(true); } + + /** Undo-replay entry point — expands without pushing a new undo marker. */ + public void expandAllSilent() { expandAllInternal(false); } + + private void expandAllInternal(boolean pushUndo) { if (folded.isEmpty()) return; String expanded = expandAllText(txt.getText(), folded); @@ -282,24 +346,79 @@ public void expandAll() { folded.clear(); recomputeRanges(); + if (pushUndo) editor.pushFoldUndo(EditorComposite.FOLD_OP_EXPAND_ALL, -1); + } + + /** Undo-replay: collapse the range whose header is at {@code line}, without pushing undo. */ + public void collapseAtLineSilent(int line) { + FoldableRange fr = foldableAtLine(line); + if (fr != null && foldedAtLine(line) == null) collapseInternal(fr, false); } - private void collapse(FoldableRange range) { + /** Undo-replay: expand the fold whose header is at {@code line}, without pushing undo. */ + public void expandAtLineSilent(int line) { + FoldRegion fr = foldedAtLine(line); + if (fr != null) expandInternal(fr, false); + } + + private void collapse(FoldableRange range) { collapseInternal(range, true); } + + private void collapseInternal(FoldableRange range, boolean pushUndo) { // Expand any nested folds inside this range so the captured hidden text is complete. + // In batch mode, prior iterations have shifted folded entries' *current* + // headerLines upward as siblings collapsed above them, so a sibling below + // this range can end up with a current headerLine inside the stale + // range.endLine. Use originalHeaderLine — the batch-t=0 coord — so the + // test stays consistent with the snapshot range's own t=0 bounds. ArrayList nested = new ArrayList(); for (int i = 0; i < folded.size(); i++) { FoldRegion fr = folded.get(i); - if (fr.headerLine > range.headerLine && fr.headerLine <= range.endLine) nested.add(fr); + int probe = batchMode ? fr.originalHeaderLine : fr.headerLine; + if (probe > range.headerLine && probe <= range.endLine) nested.add(fr); } Collections.sort(nested, new Comparator() { public int compare(FoldRegion a, FoldRegion b) { return b.headerLine - a.headerLine; } }); - for (int i = 0; i < nested.size(); i++) expand(nested.get(i)); - // Reacquire the range after any line shifts from the nested expansions. - FoldableRange fresh = foldableAtLine(range.headerLine); - if (fresh == null) return; - range = fresh; + // Count lines/chars of nested folds that existed BEFORE the batch + // started (user pre-folds). The batch-collapse snapshot `range` already + // reflects a buffer state where those lines were hidden, so restoring + // them via expandInternal pushes range.endLine/endTokenOffset down by + // exactly these totals. Batch-added nested folds do NOT count — the + // snapshot pre-dates their collapse, so expanding them just returns + // the buffer to the snapshot's state. + int preBatchLines = 0; + int preBatchChars = 0; + if (batchMode && preBatchHiddenTexts != null) { + for (int i = 0; i < nested.size(); i++) { + FoldRegion nfr = nested.get(i); + if (preBatchHiddenTexts.contains(nfr.hiddenText)) { + preBatchLines += countNewlines(nfr.hiddenText); + preBatchChars += nfr.hiddenText.length(); + } + } + } + + for (int i = 0; i < nested.size(); i++) expandInternal(nested.get(i), false); + + if (batchMode) { + // Adjust the snapshot range in place — avoids the expensive + // parser.reparseAll() + recomputeRanges() + foldableAtLine re-fetch + // that non-batch mode relies on. + if (preBatchLines > 0 || preBatchChars > 0) { + range = new FoldableRange( + range.headerLine, + range.endLine + preBatchLines, + range.endTokenOffset + preBatchChars, + range.bracket); + } + } else { + // Non-batch: each expandInternal already recomputed foldable, so + // re-fetching returns the up-to-date range. + FoldableRange fresh = foldableAtLine(range.headerLine); + if (fresh == null) return; + range = fresh; + } int lineCount = txt.getLineCount(); if (range.headerLine + 1 >= lineCount) return; @@ -327,27 +446,41 @@ private void collapse(FoldableRange range) { txt.setRedraw(true); if (parser != null) { parser.setReparse(true); - parser.reparseAll(); + if (!batchMode) parser.reparseAll(); } inFoldOp = false; } int removedLines = linesBefore - txt.getLineCount(); + // Sanity: the number of newlines captured must match what the buffer lost, + // or every shift derived from removedLines below is off. This has caught + // trailing-newline edge cases in the past where sliceEnd=charCount but the + // last line had no terminator; log loudly if it ever fires again. + int hiddenNL = countNewlines(hidden); + if (hiddenNL != removedLines) { + System.err.println("FoldingManager.collapse: removedLines/hiddenNL mismatch — removed=" + removedLines + + " hiddenNL=" + hiddenNL + " header=" + range.headerLine + " end=" + range.endLine + + " bracket=" + range.bracket + "; using removedLines for shifts"); + } // Compute the original last visible line that moved up by removedLines. // After collapse: the "]" (bracket case) or nothing extra stays at headerLine+1. int breakLine = range.bracket ? range.headerLine : range.endLine; for (int i = 0; i < folded.size(); i++) { FoldRegion fr = folded.get(i); if (fr.headerLine > breakLine) { - folded.set(i, new FoldRegion(fr.headerLine - removedLines, fr.hiddenText)); + folded.set(i, new FoldRegion(fr.headerLine - removedLines, fr.hiddenText, fr.originalHeaderLine)); } } folded.add(new FoldRegion(range.headerLine, hidden)); - recomputeRanges(); + if (!batchMode) recomputeRanges(); + if (pushUndo) editor.pushFoldUndo(EditorComposite.FOLD_OP_COLLAPSE, range.headerLine); } - private void expand(FoldRegion region) { + private void expand(FoldRegion region) { expandInternal(region, true); } + + private void expandInternal(FoldRegion region, boolean pushUndo) { + int origHeaderLine = region.headerLine; int lineCount = txt.getLineCount(); int insertOffset = (region.headerLine + 1 >= lineCount) ? txt.getCharCount() @@ -362,7 +495,7 @@ private void expand(FoldRegion region) { txt.setRedraw(true); if (parser != null) { parser.setReparse(true); - parser.reparseAll(); + if (!batchMode) parser.reparseAll(); } inFoldOp = false; } @@ -372,11 +505,12 @@ private void expand(FoldRegion region) { for (int i = 0; i < folded.size(); i++) { FoldRegion fr = folded.get(i); if (fr.headerLine > region.headerLine) { - folded.set(i, new FoldRegion(fr.headerLine + addedLines, fr.hiddenText)); + folded.set(i, new FoldRegion(fr.headerLine + addedLines, fr.hiddenText, fr.originalHeaderLine)); } } - recomputeRanges(); + if (!batchMode) recomputeRanges(); + if (pushUndo) editor.pushFoldUndo(EditorComposite.FOLD_OP_EXPAND, origHeaderLine); } /** @@ -412,6 +546,13 @@ private static int offsetAtLineStart(CharSequence cs, int line) { if (found == line) return i + 1; } } + // Diagnostic: reaching here means a fold's stored headerLine exceeds the + // visible text's line count. Appending at EOF matches the historical + // "sections ended up at the bottom of the file, out of order" symptom. + // Log so upstream shift bugs (in shiftFoldsBelow / collapse / modifyText) + // surface instead of silently corrupting the file. + System.err.println("FoldingManager.offsetAtLineStart: requested line " + line + + " but visible text only has " + found + " newline(s); appending at EOF"); return cs.length(); } From 73992abff91164e9bb119991f359176b7a0ae56a Mon Sep 17 00:00:00 2001 From: rakasha681 Date: Sat, 25 Apr 2026 17:04:33 -0400 Subject: [PATCH 04/10] (feat) Add support for including folded sections in find/replace functionality and enhance parser to handle hidden text --- com/repdev/Config.java | 17 +++ com/repdev/EditorComposite.java | 165 ++++++++++++++++++++-- com/repdev/FindReplaceShell.java | 91 +++++++++++- com/repdev/FoldingManager.java | 49 ++++++- com/repdev/parser/HiddenTextProvider.java | 41 ++++++ com/repdev/parser/RepgenParser.java | 46 ++++++ 6 files changed, 391 insertions(+), 18 deletions(-) create mode 100644 com/repdev/parser/HiddenTextProvider.java diff --git a/com/repdev/Config.java b/com/repdev/Config.java index 7ff1f43..cd7a69a 100644 --- a/com/repdev/Config.java +++ b/com/repdev/Config.java @@ -63,6 +63,7 @@ public class Config implements Serializable { private boolean windowMaximized; private Point windowSize; private boolean listUnusedVars, wrapSearch, caseSensitive, neverTerminateKeepAlive; + private boolean includeFoldedSections; private int terminateHour; private int terminateMinute; private int sashHSize, sashVSize; @@ -436,6 +437,22 @@ public static boolean getCaseSensitive(){ public static void setCaseSensitive(boolean b){ me.caseSensitive = b; } + + /** + * Return true if Include Folded Sections is checked in the FindReplaceShell dialogue box. + * @return boolean + */ + public static boolean getIncludeFoldedSections(){ + return me.includeFoldedSections; + } + + /** + * Set this to true if Include Folded Sections is checked in the FindReplaceShell dialogue box. + * @param boolean + */ + public static void setIncludeFoldedSections(boolean b){ + me.includeFoldedSections = b; + } /** * Returns the current version in the repdev.conf file diff --git a/com/repdev/EditorComposite.java b/com/repdev/EditorComposite.java index a490784..226d22a 100644 --- a/com/repdev/EditorComposite.java +++ b/com/repdev/EditorComposite.java @@ -618,6 +618,10 @@ private void buildGUI() { // Code folding - only meaningful for RepGen (parser provides head/end tokens) if (file.getType() == FileType.REPGEN) { folding = new FoldingManager(this, txt, parser); + // Let the parser see hidden text for usage scans (unused-var check), + // otherwise variables referenced only inside a folded region are + // flagged as unused even though they're really in use. + parser.setHiddenTextProvider(folding); } txt.addDisposeListener(new DisposeListener(){ @@ -1450,20 +1454,31 @@ public void menuShown(MenuEvent e) { } public void gotoDefinition(){ - + gotoDefinitionImpl(true); + } + + /** + * @param tryFoldExpansion when true, allows one fallback pass that expands + * a folded region containing the symbol and retries the lookup. Folded + * regions are physically removed from the buffer so the parser's tokens / + * variables / sections don't include them — without this fallback, jumping + * into a definition that's currently folded would silently fail. + */ + private void gotoDefinitionImpl(boolean tryFoldExpansion){ + CTabFolder mainfolder = RepDevMain.mainShell.getMainfolder(); - + HashMap> incTokenCache = parser.getIncludeTokenChache(); String selString=txt.getSelectionText(); if(selString.length() == 0) selString=getTokenAt(txt.getCaretOffset()) != null ? getTokenAt(txt.getCaretOffset()).getStr() : ""; if(!isAlphaNumeric(selString)) return; - - + + if( parser.needRefreshIncludes() ) parser.parseIncludes(); - + try { //sec = new BackgroundSectionParser(parser.getLtokens(),txt.getText()); //sec.refreshList(parser.getLtokens(),txt.getText()); @@ -1481,17 +1496,17 @@ public void gotoDefinition(){ if(matchVarAndGoto(var, selString)) return; } - // Go through open files which include this file. Search for Variables/Procedures and goto. + // Go through open files which include this file. Search for Variables/Procedures and goto. for(CTabItem tf : mainfolder.getItems()){ if(tf.getControl() instanceof EditorComposite) { EditorComposite ec = ((EditorComposite) tf.getControl()); incTokenCache = ec.parser.getIncludeTokenChache(); for( String key : incTokenCache.keySet()){ if(key.equalsIgnoreCase(file.getName())){ - + if( ec.parser.needRefreshIncludes() ) ec.parser.parseIncludes(); - + if(ec.sec.exist(selString)){ gotoSection(selString); return; @@ -1516,9 +1531,139 @@ public void gotoDefinition(){ dialog.setMessage("The include file may have been modified. Please save this RepGen and Try again."); dialog.setText("Jump to Procedure"); dialog.open(); + return; + } + + // Nothing matched in any visible token / var / section list. If the + // definition lives inside a currently-folded region, the parser can't + // see it — try expanding the fold containing the symbol and either + // retry the lookup (variable case) or jump inline (procedure case). + if (tryFoldExpansion && folding != null && folding.hasActiveFolds()) { + int result = expandFoldContainingSymbol(selString); + if (result == FOLD_EXPANSION_RETRY) { + gotoDefinitionImpl(false); + } + // FOLD_EXPANSION_JUMPED: nothing more to do — handled inline. + // FOLD_EXPANSION_NONE: silent fall-through, same as before. + } + } + + private static final int FOLD_EXPANSION_NONE = 0; + private static final int FOLD_EXPANSION_RETRY = 1; + private static final int FOLD_EXPANSION_JUMPED = 2; + + /** + * Expands a folded region whose body holds the definition of {@code symbol}. + * Two shapes count as a definition: + *
    + *
  • DEFINE-headed fold whose hidden body contains + * {@code symbol=...} — variable declaration inside a folded + * {@code DEFINE...END} block. An {@code =} anywhere else is an + * assignment, not a definition, and is ignored.
  • + *
  • PROCEDURE-headed fold whose visible header line is + * {@code procedure symbol}. The header stays visible after fold, but + * {@link BackgroundSectionParser} only registers a section once its + * matching {@code END} is seen at depth 0 — so the procedure isn't + * in {@code sec} until the body is restored.
  • + *
+ * + *

After any expansion, also refresh {@code sec} so the retry pass can + * find the freshly-completed section (the normal refresh fires on caret + * move, which hasn't happened here). + */ + private int expandFoldContainingSymbol(String symbol) { + if (symbol == null || symbol.length() == 0 || folding == null) return FOLD_EXPANSION_NONE; + String needle = symbol.toLowerCase(); + for (FoldingManager.FoldRegion fr : folding.getFoldedRegions()) { + if (isProcedureHeaderForSymbol(fr.headerLine, needle)) { + int headerLine = fr.headerLine; + folding.toggleAtLine(headerLine); + // Jump inline rather than retrying through sec.exist(). + // BackgroundSectionParser.refreshList runs the parse on a + // background thread, racing with our retry — the retry can + // acquire the synchronized lock before the parse thread does + // and read the pre-expansion section list. Since we already + // know the header line, jump to it directly. + try { + int target = txt.getOffsetAtLine(headerLine); + txt.setCaretOffset(txt.getCharCount()); + txt.showSelection(); + txt.setCaretOffset(target); + handleCaretChange(); + RepDevMain.mainShell.addToNavHistory(file, txt.getLineAtOffset(txt.getCaretOffset())); + txt.showSelection(); + lineHighlight(); + } catch (IllegalArgumentException ex) { + // Expansion already happened; just couldn't position the caret. + } + return FOLD_EXPANSION_JUMPED; + } + if (isDefineHeaderLine(fr.headerLine) + && containsAssignmentOf(fr.hiddenText.toLowerCase(), needle)) { + folding.toggleAtLine(fr.headerLine); + return FOLD_EXPANSION_RETRY; + } + } + return FOLD_EXPANSION_NONE; + } + + /** True if the visible buffer line at {@code line} starts with the DEFINE keyword. */ + private boolean isDefineHeaderLine(int line) { + String trimmed = trimmedLowerLine(line); + if (trimmed == null) return false; + return trimmed.equals("define") + || trimmed.startsWith("define ") + || trimmed.startsWith("define\t"); + } + + /** + * True if the visible buffer line at {@code line} is {@code PROCEDURE name} + * with {@code name} (lowercased) equal to {@code lowerSymbol}. + */ + private boolean isProcedureHeaderForSymbol(int line, String lowerSymbol) { + String trimmed = trimmedLowerLine(line); + if (trimmed == null || !trimmed.startsWith("procedure")) return false; + int kwEnd = "procedure".length(); + if (kwEnd >= trimmed.length()) return false; + // Must have a non-identifier separator after the keyword (whitespace etc.) + if (Character.isLetterOrDigit(trimmed.charAt(kwEnd))) return false; + String rest = trimmed.substring(kwEnd).trim(); + if (!rest.startsWith(lowerSymbol)) return false; + int after = lowerSymbol.length(); + if (after >= rest.length()) return true; + return !Character.isLetterOrDigit(rest.charAt(after)); + } + + private String trimmedLowerLine(int line) { + if (line < 0 || line >= txt.getLineCount()) return null; + try { return txt.getLine(line).trim().toLowerCase(); } + catch (IllegalArgumentException ex) { return null; } + } + + /** + * True if {@code word} appears in {@code haystack} as a whole word + * immediately followed (skipping spaces/tabs) by {@code =} — the shape of + * a RepGen variable assignment. Both inputs must already be lowercased. + */ + private static boolean containsAssignmentOf(String haystack, String word) { + int from = 0; + while (true) { + int idx = haystack.indexOf(word, from); + if (idx < 0) return false; + char before = idx == 0 ? ' ' : haystack.charAt(idx - 1); + if (Character.isLetterOrDigit(before)) { from = idx + 1; continue; } + int afterIdx = idx + word.length(); + char after = afterIdx >= haystack.length() ? ' ' : haystack.charAt(afterIdx); + if (Character.isLetterOrDigit(after)) { from = idx + 1; continue; } + int p = afterIdx; + while (p < haystack.length()) { + char c = haystack.charAt(p); + if (c == ' ' || c == '\t') { p++; continue; } + if (c == '=') return true; + break; + } + from = idx + 1; } - - } private Boolean matchVarAndGoto(Variable var, String varToMatch){ Object o; diff --git a/com/repdev/FindReplaceShell.java b/com/repdev/FindReplaceShell.java index 9245d0a..7a2cdc1 100644 --- a/com/repdev/FindReplaceShell.java +++ b/com/repdev/FindReplaceShell.java @@ -43,7 +43,7 @@ public class FindReplaceShell { private RepgenParser parser; //Only used to disable it for replace All operations, can always be null private Label infoLabel; private Text findText, replaceText; - private Button forwardButton,backwardButton,caseButton, wrapButton, findButton, replaceButton, replaceAllButton, replaceFindButton; + private Button forwardButton,backwardButton,caseButton, wrapButton, includeFoldedButton, findButton, replaceButton, replaceAllButton, replaceFindButton; private boolean replace = true; public FindReplaceShell(Shell parent){ @@ -149,7 +149,7 @@ public void widgetSelected(SelectionEvent e){ }); wrapButton = new Button(optionsGroup,SWT.CHECK); - wrapButton.setText("Wrap search"); + wrapButton.setText("Wrap search"); wrapButton.setSelection(Config.getWrapSearch()); wrapButton.addSelectionListener(new SelectionAdapter(){ public void widgetSelected(SelectionEvent e){ @@ -161,7 +161,17 @@ public void widgetSelected(SelectionEvent e){ } } }); - + + includeFoldedButton = new Button(optionsGroup,SWT.CHECK); + includeFoldedButton.setText("Include folded sections"); + includeFoldedButton.setSelection(Config.getIncludeFoldedSections()); + includeFoldedButton.addSelectionListener(new SelectionAdapter(){ + public void widgetSelected(SelectionEvent e){ + Config.setIncludeFoldedSections(includeFoldedButton.getSelection()); + } + }); + + findButton = new Button(shell,SWT.NONE); findButton.setText("Find"); findButton.addSelectionListener(new SelectionAdapter(){ @@ -241,16 +251,19 @@ public void widgetSelected(SelectionEvent e){ data.top = new FormAttachment(replaceText); optionsGroup.setLayoutData(data); + // Anchor below optionsGroup since it now has 3 children and is taller + // than directionGroup (2 radios). Without this, options overflows the + // action buttons. data = new FormData(); data.width = buttonWidth; data.left = new FormAttachment(0); - data.top = new FormAttachment(directionGroup); + data.top = new FormAttachment(optionsGroup); findButton.setLayoutData(data); - + data = new FormData(); data.width = buttonWidth; data.left = new FormAttachment(findButton); - data.top = new FormAttachment(directionGroup); + data.top = new FormAttachment(optionsGroup); replaceFindButton.setLayoutData(data); data = new FormData(); @@ -406,6 +419,13 @@ protected boolean find() { if( nextPos == -1) { + // Fallback: look inside collapsed fold regions. On a hit, expand the + // best-positioned fold for the current direction and retry — the + // previously hidden text is now in the buffer, so the standard + // search path will land on it. + if (includeFoldedButton.getSelection() && expandFoldContaining(findText.getText())) { + return find(); + } infoLabel.setText("String not found"); return false; } @@ -415,7 +435,64 @@ protected boolean find() { if(txt.getParent() instanceof EditorComposite) RepDevMain.mainShell.addToNavHistory(((EditorComposite)txt.getParent()).getFile(), txt.getLineAtOffset(txt.getCaretOffset())); } - + + return true; + } + + /** + * Scan currently-folded regions for {@code findStr} and, if found, expand + * the region best-suited to the current search direction. After return, the + * caller should retry the normal find loop — the hit is now visible. + * + * Direction handling: forward search prefers the closest fold whose + * header is at/after the caret line so the retry doesn't need wrap. Backward + * prefers the closest fold strictly before the caret line. If no fold in + * the current direction matches, falls back to any fold (only when wrap is + * on, since the retry needs wrap to reach matches behind/ahead of caret). + */ + private boolean expandFoldContaining(String findStr) { + if (findStr == null || findStr.length() == 0) return false; + if (!(txt.getParent() instanceof EditorComposite)) return false; + FoldingManager folding = ((EditorComposite) txt.getParent()).getFolding(); + if (folding == null || !folding.hasActiveFolds()) return false; + + boolean caseSensitive = caseButton.getSelection(); + String needle = caseSensitive ? findStr : findStr.toLowerCase(); + boolean forward = forwardButton.getSelection(); + boolean wrap = wrapButton.getSelection(); + + int caretLine; + try { caretLine = txt.getLineAtOffset(txt.getCaretOffset()); } + catch (IllegalArgumentException ex) { return false; } + + FoldingManager.FoldRegion bestInDir = null; + FoldingManager.FoldRegion bestAny = null; + for (FoldingManager.FoldRegion fr : folding.getFoldedRegions()) { + String hay = caseSensitive ? fr.hiddenText : fr.hiddenText.toLowerCase(); + if (!hay.contains(needle)) continue; + + // Track the closest in document order, used as the wrap fallback. + if (bestAny == null + || (forward && fr.headerLine < bestAny.headerLine) + || (!forward && fr.headerLine > bestAny.headerLine)) { + bestAny = fr; + } + // Forward includes headerLine == caretLine: the body sits at + // headerLine+1+ in the buffer, which is past the caret offset. + // Backward must be strict: a fold at the caret line lives after + // the caret, so backward find() wouldn't reach it. + boolean inDir = forward ? (fr.headerLine >= caretLine) : (fr.headerLine < caretLine); + if (!inDir) continue; + if (bestInDir == null + || (forward && fr.headerLine < bestInDir.headerLine) + || (!forward && fr.headerLine > bestInDir.headerLine)) { + bestInDir = fr; + } + } + + FoldingManager.FoldRegion pick = bestInDir != null ? bestInDir : (wrap ? bestAny : null); + if (pick == null) return false; + folding.toggleAtLine(pick.headerLine); return true; } } diff --git a/com/repdev/FoldingManager.java b/com/repdev/FoldingManager.java index 8260e5d..f3b19ea 100644 --- a/com/repdev/FoldingManager.java +++ b/com/repdev/FoldingManager.java @@ -34,6 +34,7 @@ import org.eclipse.swt.graphics.GC; import org.eclipse.swt.graphics.RGB; +import com.repdev.parser.HiddenTextProvider; import com.repdev.parser.RepgenParser; import com.repdev.parser.Token; @@ -47,7 +48,7 @@ * StyledText buffer and caching them in-memory. On save, getUnfoldedText() * reassembles the full source. */ -public class FoldingManager { +public class FoldingManager implements HiddenTextProvider { /** Width in pixels of the gutter column that holds the fold triangle. */ public static final int FOLD_COLUMN_WIDTH = 18; @@ -147,6 +148,52 @@ public void mouseDown(MouseEvent e) { public boolean hasActiveFolds() { return !folded.isEmpty(); } + /** + * Defensive snapshot of the currently-collapsed regions. Callers (e.g. + * Find/Replace, goto-definition) need to inspect hidden text without being + * able to mutate the manager's own list. + */ + public ArrayList getFoldedRegions() { + return new ArrayList(folded); + } + + /** + * Hidden text segments suitable for usage scans (e.g. unused-variable + * detection). Skips folds whose body would yield false positives: + *

    + *
  • {@code DEFINE...END} bodies — appearances there are declarations, + * not usages, so counting them as references would mask a genuinely + * unused variable.
  • + *
  • Bracket comment bodies ({@code [...]}) — comments don't count as + * usages either, matching the live parser's {@code tok.getCDepth() > 0} + * filter on visible tokens.
  • + *
+ */ + public Iterable getUsageSearchableHiddenText() { + ArrayList result = new ArrayList(); + for (int i = 0; i < folded.size(); i++) { + FoldRegion fr = folded.get(i); + String header = headerLineText(fr.headerLine); + if (header == null) continue; + String trimmed = header.trim(); + String lower = trimmed.toLowerCase(); + // Skip folded DEFINE blocks: their hidden body is declarations. + if (lower.equals("define") + || lower.startsWith("define ") + || lower.startsWith("define\t")) continue; + // Skip bracket comment folds: header line opens with '['. + if (trimmed.length() > 0 && trimmed.charAt(0) == '[') continue; + result.add(fr.hiddenText); + } + return result; + } + + private String headerLineText(int line) { + if (line < 0 || line >= txt.getLineCount()) return null; + try { return txt.getLine(line); } + catch (IllegalArgumentException ex) { return null; } + } + public int getDisplayLineNumber(int visibleLine) { return displayLineNumberFor(visibleLine, folded); } diff --git a/com/repdev/parser/HiddenTextProvider.java b/com/repdev/parser/HiddenTextProvider.java new file mode 100644 index 0000000..9813f78 --- /dev/null +++ b/com/repdev/parser/HiddenTextProvider.java @@ -0,0 +1,41 @@ +/** + * RepDev - RepGen IDE for Symitar + * Copyright (C) 2007 Jake Poznanski, Ryan Schultz, Sean Delaney + * http://repdev.org/ + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package com.repdev.parser; + +/** + * Lets the parser ask for text segments that exist in the document but have + * been hidden from the {@link org.eclipse.swt.custom.StyledText} buffer (e.g. + * collapsed fold regions). + * + * The interface lives in the parser package so {@code RepgenParser} can + * depend on it without importing back into {@code com.repdev}, avoiding a + * package-cycle. The implementation (currently {@code FoldingManager}) is + * responsible for excluding segments where token-shape scans don't make + * sense — bodies of folded {@code DEFINE...END} blocks (declarations, not + * usages) and bracket comment bodies. + */ +public interface HiddenTextProvider { + + /** + * Snapshot of currently-hidden text segments suitable for usage scans — + * i.e. excluding declaration bodies and comment bodies. + */ + Iterable getUsageSearchableHiddenText(); +} diff --git a/com/repdev/parser/RepgenParser.java b/com/repdev/parser/RepgenParser.java index 527dcf2..9cb7272 100644 --- a/com/repdev/parser/RepgenParser.java +++ b/com/repdev/parser/RepgenParser.java @@ -70,6 +70,15 @@ public class RepgenParser { BackgroundSymitarErrorChecker errorCheckerWorker = null; BackgroundIncludeParser includeParserWorker = null; + // Optional source of currently-hidden text (e.g. collapsed folds). When + // set, usage scans (unused-var check) also walk these segments so that a + // variable referenced only inside a folded region isn't flagged unused. + private HiddenTextProvider hiddenTextProvider = null; + + public void setHiddenTextProvider(HiddenTextProvider p) { + this.hiddenTextProvider = p; + } + boolean initialIncludeParseNeeded = true; //This will make sure that we parse the includes at least once when the file is first opened boolean refreshIncludes = false; //The parser will keep track of changes as the file is edited, and if an include reparse is needed, this flag will be set. //Since include parsing is resource intensive, it's up to the rest of the code to decide when to parse these if needed. (Usually on file save) @@ -312,6 +321,23 @@ public void run() { } } + // Hidden-text fallback: a variable used only inside a + // collapsed fold won't appear in ltokens (folding + // physically removes the lines from the buffer), so the + // visible-token scans above can flag it as unused. Scan + // the fold body text directly. The provider already + // excludes DEFINE bodies (declarations) and bracket + // comments (not usages). + if (unused && hiddenTextProvider != null) { + String varNameLower = var.getName().toLowerCase(); + for (String hidden : hiddenTextProvider.getUsageSearchableHiddenText()) { + if (containsWord(hidden.toLowerCase(), varNameLower)) { + unused = false; + break; + } + } + } + if( unused && !tblErrors.isDisposed()){ display.syncExec(new Runnable() { public void run() { @@ -1182,6 +1208,26 @@ public static KeywordLayout getKeywords() { return keywords; } + /** + * Whole-word containment test: matches when neither neighboring char is a + * letter or digit. RepGen identifiers are alphanumeric, so this matches the + * token-boundary semantics used elsewhere. Both inputs must already be + * lowercased if a case-insensitive match is wanted. + */ + private static boolean containsWord(String haystack, String word) { + if (haystack == null || word == null || word.length() == 0) return false; + int from = 0; + while (true) { + int idx = haystack.indexOf(word, from); + if (idx < 0) return false; + char before = idx == 0 ? ' ' : haystack.charAt(idx - 1); + int afterIdx = idx + word.length(); + char after = afterIdx >= haystack.length() ? ' ' : haystack.charAt(afterIdx); + if (!Character.isLetterOrDigit(before) && !Character.isLetterOrDigit(after)) return true; + from = idx + 1; + } + } + public static void setKeywords(KeywordLayout keywords) { RepgenParser.keywords = keywords; } From 99c0d31ebfa70492417f2a7aacd153774365d9bc Mon Sep 17 00:00:00 2001 From: rakasha681 Date: Sun, 26 Apr 2026 18:45:45 -0400 Subject: [PATCH 05/10] (fix) Prevent orphaned bracket closers from mispairing folded regions in the FoldingManager --- com/repdev/FoldingManager.java | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/com/repdev/FoldingManager.java b/com/repdev/FoldingManager.java index f3b19ea..9df1e09 100644 --- a/com/repdev/FoldingManager.java +++ b/com/repdev/FoldingManager.java @@ -230,8 +230,24 @@ public void recomputeRanges() { // Head tokens on folded header lines are orphans: their matching end // has been removed from the buffer, so pushing them onto the stack // would mispair outer regions. Collect those lines up front and skip. + // Bracket folds also leave an orphan ']' in the buffer at headerLine+1 + // (the bracket fold preserves the closer so the comment highlighter + // still sees it). Without skipping it too, the ']' pops whatever outer + // head is on the stack — e.g. a PROCEDURE that contains the folded + // bracket — and produces a bogus FoldableRange that ends at the ']' + // instead of the real END. That misclick then strands the inner + // folds (DO/END etc.) outside the new fold's hiddenText, leaving them + // as ghost entries with headerLines past the visible buffer. HashSet foldedHeaderLines = new HashSet(); - for (int i = 0; i < folded.size(); i++) foldedHeaderLines.add(folded.get(i).headerLine); + HashSet orphanCloserLines = new HashSet(); + for (int i = 0; i < folded.size(); i++) { + FoldRegion fr = folded.get(i); + foldedHeaderLines.add(fr.headerLine); + String header = headerLineText(fr.headerLine); + if (header != null && header.trim().startsWith("[")) { + orphanCloserLines.add(fr.headerLine + 1); + } + } Stack stack = new Stack(); int charCount = txt.getCharCount(); @@ -250,6 +266,15 @@ public void recomputeRanges() { if (foldedHeaderLines.contains(tLine)) continue; // orphan head inside a collapsed region stack.push(i); } else if (t.isRealEnd() && !")".equals(s) && !"\"".equals(s) && !"'".equals(s)) { + if ("]".equals(s) && !orphanCloserLines.isEmpty()) { + int tStart = t.getStart(); + if (tStart >= 0 && tStart < charCount) { + try { + int tLine = txt.getLineAtOffset(tStart); + if (orphanCloserLines.contains(tLine)) continue; + } catch (IllegalArgumentException ignored) { } + } + } if (!stack.isEmpty()) { int headIdx = stack.pop(); Token head = tokens.get(headIdx); From 0806c72a2bc6042726942ad979839e8d40f23db3 Mon Sep 17 00:00:00 2001 From: rakasha681 Date: Wed, 13 May 2026 10:24:49 -0400 Subject: [PATCH 06/10] (feat) Add getFullSourceText method to HiddenTextProvider and update RepgenParser to utilize canonical source for variable management which fixes a lot of our current folding bugs (step 1 in model/view split, step 2 will follow with full implementation, which will prevent all similar bugs from happening going forward) --- com/repdev/FoldingManager.java | 4 + com/repdev/parser/HiddenTextProvider.java | 12 +++ com/repdev/parser/RepgenParser.java | 104 ++++++++++++++++++---- 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/com/repdev/FoldingManager.java b/com/repdev/FoldingManager.java index 9df1e09..a067f21 100644 --- a/com/repdev/FoldingManager.java +++ b/com/repdev/FoldingManager.java @@ -169,6 +169,10 @@ public ArrayList getFoldedRegions() { * filter on visible tokens. * */ + public String getFullSourceText() { + return getUnfoldedText(); + } + public Iterable getUsageSearchableHiddenText() { ArrayList result = new ArrayList(); for (int i = 0; i < folded.size(); i++) { diff --git a/com/repdev/parser/HiddenTextProvider.java b/com/repdev/parser/HiddenTextProvider.java index 9813f78..ac21f5c 100644 --- a/com/repdev/parser/HiddenTextProvider.java +++ b/com/repdev/parser/HiddenTextProvider.java @@ -38,4 +38,16 @@ public interface HiddenTextProvider { * i.e. excluding declaration bodies and comment bodies. */ Iterable getUsageSearchableHiddenText(); + + /** + * Snapshot of the canonical (unfolded) source text. The parser uses this + * for variable discovery and error-position arithmetic so that content + * hidden inside collapsed folds still contributes to the symbol table and + * error reports. + * + *

Returning {@code null} signals "no projection in effect" — callers + * fall back to the live {@code StyledText} buffer. Implementations that + * always project the full source should always return non-null. + */ + String getFullSourceText(); } diff --git a/com/repdev/parser/RepgenParser.java b/com/repdev/parser/RepgenParser.java index 9cb7272..3177b12 100644 --- a/com/repdev/parser/RepgenParser.java +++ b/com/repdev/parser/RepgenParser.java @@ -232,6 +232,22 @@ public void run() { try { Display display = tblErrors.getDisplay(); + // Snapshot the canonical (unfolded) source up front. Variables + // declared inside a collapsed fold hold model offsets that + // exceed txt.getCharCount(); computing line/col against this + // string rather than the live StyledText buffer keeps the + // worker from throwing IllegalArgumentException and silently + // aborting before any rows reach tblErrors. + final String[] canonicalSourceHolder = new String[1]; + display.syncExec(new Runnable() { + public void run() { + if (txt.isDisposed()) return; + String full = (hiddenTextProvider != null) ? hiddenTextProvider.getFullSourceText() : null; + canonicalSourceHolder[0] = (full != null) ? full : txt.getText(); + } + }); + final String canonicalSource = (canonicalSourceHolder[0] != null) ? canonicalSourceHolder[0] : ""; + // Remove old errors errorList.clear(); taskList.clear(); @@ -273,13 +289,10 @@ public void run() { if (var2.equals(var)) count++; - if (count > 1 && !tblErrors.isDisposed()) - display.syncExec(new Runnable() { - public void run() { - if (!txt.isDisposed()) - errorList.add(new Error(file.getName(), "Duplicate variable name: " + var.getName().toUpperCase(), txt.getLineAtOffset(var.getPos()) + 1, var.getPos() - txt.getOffsetAtLine(txt.getLineAtOffset(var.getPos())) + 1,Error.Type.WARNING)); - } - }); + if (count > 1 && !tblErrors.isDisposed()) { + int[] lc = lineColAt(canonicalSource, var.getPos()); + errorList.add(new Error(file.getName(), "Duplicate variable name: " + var.getName().toUpperCase(), lc[0] + 1, lc[1] + 1, Error.Type.WARNING)); + } } } } @@ -338,14 +351,9 @@ public void run() { } } - if( unused && !tblErrors.isDisposed()){ - display.syncExec(new Runnable() { - public void run() { - if (!txt.isDisposed() && com.repdev.Config.getListUnusedVars()){ - errorList.add(new Error(var.getFilename(), "Variable Unused: " + var.getName().toUpperCase(), txt.getLineAtOffset(var.getPos()) + 1, var.getPos() - txt.getOffsetAtLine(txt.getLineAtOffset(var.getPos())) + 1,Error.Type.WARNING)); - } - } - }); + if( unused && !tblErrors.isDisposed() && com.repdev.Config.getListUnusedVars()) { + int[] lc = lineColAt(canonicalSource, var.getPos()); + errorList.add(new Error(var.getFilename(), "Variable Unused: " + var.getName().toUpperCase(), lc[0] + 1, lc[1] + 1, Error.Type.WARNING)); } } // Redraw Main Screen after background variables are parsed @@ -1040,6 +1048,68 @@ private synchronized void rebuildVars(String fileName, String data, ArrayListIf no {@link HiddenTextProvider} is set, or if it reports the same + * text as the live buffer (no active folds), this is a passthrough to the + * existing visible-buffer rebuild — preserving prior behavior in the + * unfolded case and in compare-tab editors that don't fold. + * + *

When the canonical text differs from the buffer, a throwaway full + * parse builds a temporary token list whose positions are model + * offsets (i.e. offsets into the unfolded source). {@code rebuildVars} + * is then driven from those tokens. {@link #ltokens} itself stays anchored + * to the visible buffer so the syntax highlighter and fold-range + * computation keep working unchanged. + * + *

Side effect: {@link Variable#getPos()} for vars declared inside a + * folded region holds a model offset that exceeds {@code txt.getCharCount()}. + * Callers that pass {@code var.getPos()} to {@code StyledText.getLineAtOffset} + * must guard against that — see {@link BackgroundSymitarErrorChecker} for + * the pattern (compute line/col against the canonical source string instead + * of the live buffer). + */ + private synchronized void rebuildVarsFromCanonicalSource(String fileName) { + String fullText = (hiddenTextProvider != null) ? hiddenTextProvider.getFullSourceText() : null; + String visibleText = txt.getText(); + if (fullText == null || fullText.equals(visibleText)) { + rebuildVars(fileName, visibleText, ltokens); + return; + } + ArrayList fullTokens = new ArrayList(); + parse(fileName, fullText, 0, fullText.length() - 1, 0, null, + fullTokens, new ArrayList(), new ArrayList(), + new ArrayList(), null); + rebuildVars(fileName, fullText, fullTokens); + } + + /** + * Compute (line, column) for a model offset against a canonical source + * string. Used by the error checker so a {@link Variable#getPos()} that + * lives inside a collapsed fold doesn't trip + * {@code StyledText.getLineAtOffset} (which would throw + * {@code IllegalArgumentException} for offsets past the visible buffer, + * silently aborting the worker before any errors reach the table). + * + * @return {@code int[]{line, col}}, both zero-based. Offsets beyond the + * string clamp to the last line; negative offsets clamp to (0, 0). + */ + static int[] lineColAt(String text, int offset) { + int line = 0, col = 0; + int max = Math.min(offset, text.length()); + for (int i = 0; i < max; i++) { + if (text.charAt(i) == '\n') { line++; col = 0; } + else col++; + } + return new int[]{ line, col }; + } + public void textModified(int start, int length, String replacedText){ if (reparse) { int st = start; @@ -1104,7 +1174,7 @@ public void textModified(int start, int length, String replacedText){ if( rebuildVars ) - rebuildVars(file.getName(), txt.getText(), ltokens); + rebuildVarsFromCanonicalSource(file.getName()); if( initialIncludeParseNeeded ){ parseIncludes(); @@ -1138,7 +1208,7 @@ public void reparseAll() { try { ltokens = new ArrayList(); parse(file.getName(), txt.getText(), 0, txt.getCharCount() - 1, 0, null, ltokens, lasttokens, removedtokens, lvars, txt); - rebuildVars(file.getName(), txt.getText(), ltokens); + rebuildVarsFromCanonicalSource(file.getName()); System.out.println("Reparsed"); } catch (Exception e) { System.err.println("Syntax Highlighter error!"); From d1d28909de4ec229f6e8e2dc137fa47a8e2628d3 Mon Sep 17 00:00:00 2001 From: rakasha681 Date: Wed, 13 May 2026 11:08:30 -0400 Subject: [PATCH 07/10] (feat) Improve error checking in RepgenParser to ensure server-side checks only run for connected sessions, enhancing feedback for local files, which is needed for using and testing code folding features while not connected to symitar --- com/repdev/EditorComposite.java | 10 +++++----- com/repdev/parser/RepgenParser.java | 14 +++++++++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/com/repdev/EditorComposite.java b/com/repdev/EditorComposite.java index 226d22a..c376ba8 100644 --- a/com/repdev/EditorComposite.java +++ b/com/repdev/EditorComposite.java @@ -566,7 +566,7 @@ public Color getLineColor(){ /** Width of just the line-number column (no fold column). */ public final int calcNumberColumnWidth(){ if (!showLineNumbers) return 0; - int lastLine = (folding != null) ? folding.getUnfoldedLineCount() : txt.getLineCount(); + int lastLine = (folding != null) ? folding.getUnfoldedLineCount() : txt.getLineCount(); return (Integer.toString(lastLine).length() * 12) + 6; } @@ -778,8 +778,8 @@ public void paintControl (PaintEvent e){ int y; try { y = txt.getLocationAtOffset(txt.getOffsetAtLine(line)).y; } catch (IllegalArgumentException ex) { continue; } - int displayLine = (folding != null) ? folding.getDisplayLineNumber(line) : line + 1; - String num = String.valueOf(displayLine); + int displayLine = (folding != null) ? folding.getDisplayLineNumber(line) : line + 1; + String num = String.valueOf(displayLine); int numW = gc.textExtent(num).x; int nx = numberColW - numW - 4; if (nx < 0) nx = 0; @@ -1444,7 +1444,7 @@ public void menuShown(MenuEvent e) { frmTxt.bottom = new FormAttachment(100); txt.setLayoutData(frmTxt); - if( parser != null && !file.isLocal()) + if( parser != null ) parser.errorCheck(); undoMode = 1; @@ -1986,7 +1986,7 @@ public void saveFile( boolean errorCheck ){ } - if( parser != null && errorCheck && !file.isLocal()) + if( parser != null && errorCheck ) parser.errorCheck(); } diff --git a/com/repdev/parser/RepgenParser.java b/com/repdev/parser/RepgenParser.java index 3177b12..ace01d9 100644 --- a/com/repdev/parser/RepgenParser.java +++ b/com/repdev/parser/RepgenParser.java @@ -270,9 +270,17 @@ public void run() { checkFile = false; } } - if(checkFile){ - ErrorCheckResult result = RepDevMain.SYMITAR_SESSIONS.get(sym).errorCheckRepGen(file.getName()); - errorList.add(new Error(result)); + // Symitar server-side compile check requires a connected + // session. Local files (no host) and offline sessions skip + // this step; local-only checks (duplicate vars, unused vars) + // below still run so the user gets feedback while editing + // disconnected. + if(checkFile && !file.isLocal()){ + com.repdev.SymitarSession session = RepDevMain.SYMITAR_SESSIONS.get(sym); + if (session != null && session.isConnected()) { + ErrorCheckResult result = session.errorCheckRepGen(file.getName()); + errorList.add(new Error(result)); + } } From 1e627fc895057f06a5cf1a1b2e9bbaf15c4a245e Mon Sep 17 00:00:00 2001 From: rakasha681 Date: Wed, 13 May 2026 13:46:20 -0400 Subject: [PATCH 08/10] Enhance folding and syntax highlighting functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced ExtendedModifyListener in FoldingManager to handle text modifications and manage fold states correctly during user edits. - Implemented model-to-view and view-to-model offset translations to ensure accurate caret positioning and visibility of folded regions. - Updated SyntaxHighlighter to utilize model coordinates for styling tokens, ensuring correct visual representation even when parts of the text are folded. - Refactored task scanning in RepgenParser to derive line and column information from the canonical source, allowing for accurate task reporting regardless of folding state. - Enhanced error handling and navigation in MainShell to accommodate model coordinates, ensuring that error and task highlights remain functional with folded text. - Added methods in HiddenTextProvider interface for translating between model and view offsets, facilitating better integration with the folding mechanism. The parser layer (ltokens, lvars, section info, error positions, task positions) is anchored to the canonical (unfolded) source. Every offset stored there is a model offset and remains correct across fold/expand cycles. The view (StyledText buffer) is treated as a projection; UI-bound code paths translate at the boundary via three places only: FoldingManager's translation API (raw helpers), EditorComposite's tokenStart/EndView + gotoModel* (token/offset helpers), and SyntaxHighlighter.lineGetStyle (per-line styling translation). - IntelliSense sees DEFINE-folded variables. - Symitar error rows always reach the table, even while folded. - Click an error row while its line is inside a fold → fold auto-expands and caret lands on the correct line. - Goto definition into a folded var/procedure auto-expands. - Goto section / section navigation works against folded files. - Block matcher (Ctrl+Shift+P) finds the matching head/end even if one side is inside a fold. - Tasks (TODO / FIXME / etc.) discovered inside folded regions are listed and clickable. --- com/repdev/EditorComposite.java | 296 +++++++++++++--------- com/repdev/FoldingManager.java | 237 ++++++++++++++++- com/repdev/MainShell.java | 39 ++- com/repdev/SyntaxHighlighter.java | 96 ++++--- com/repdev/parser/HiddenTextProvider.java | 13 + com/repdev/parser/RepgenParser.java | 211 +++++++-------- 6 files changed, 613 insertions(+), 279 deletions(-) diff --git a/com/repdev/EditorComposite.java b/com/repdev/EditorComposite.java index c376ba8..011f1a8 100644 --- a/com/repdev/EditorComposite.java +++ b/com/repdev/EditorComposite.java @@ -607,22 +607,28 @@ private void buildGUI() { txt.setFont(DEFAULT_FONT); } + // Folding is constructed BEFORE the syntax highlighter so its modifyText + // listener (which performs fold-line shifts) runs first on every edit. + // SyntaxHighlighter.modifyText calls parser.textModified, which reads + // the canonical source via the HiddenTextProvider seam — that reassembly + // is only correct once fold headerLines reflect the post-edit line + // numbering. Reversing this order causes parser state to drift one edit + // behind the buffer when folds are active. + if (file.getType() == FileType.REPGEN) { + folding = new FoldingManager(this, txt, parser); + parser.setHiddenTextProvider(folding); + } + highlighter = new SyntaxHighlighter(parser); setLineColor(highlighter); final EditorComposite tempEditor = this; - // Load the Section Info - sec = new BackgroundSectionParser(parser.getLtokens(),txt.getText()); - - // Code folding - only meaningful for RepGen (parser provides head/end tokens) - if (file.getType() == FileType.REPGEN) { - folding = new FoldingManager(this, txt, parser); - // Let the parser see hidden text for usage scans (unused-var check), - // otherwise variables referenced only inside a folded region are - // flagged as unused even though they're really in use. - parser.setHiddenTextProvider(folding); - } + // Load the Section Info — feed it the canonical source so section + // positions stay valid when folds hide a section body. Consumers of + // SectionInfo.getPos() / getFirstInsertPos() / getLastInsertPos() must + // translate model→view via folding before indexing into the StyledText. + sec = new BackgroundSectionParser(parser.getLtokens(), parser.canonicalSourceText()); txt.addDisposeListener(new DisposeListener(){ @@ -793,42 +799,10 @@ public void paintControl (PaintEvent e){ public void modifyText(ExtendedModifyEvent event) { lineHighlight(); - - // Keep folded region line numbers in sync with edits above them. - // Only needed for user edits; fold/unfold operations manage their own shifts. - if (folding != null && !folding.isInFoldOp() && folding.hasActiveFolds()) { - String inserted = ""; - // Bounds-check before reading the inserted slice — silently - // catching here would miscount addedNL and leave folds with - // wrong headerLines, which later drops them at EOF (see - // FoldingManager.offsetAtLineStart diagnostic). - int charCount = txt.getCharCount(); - if (event.length > 0 && event.start >= 0 && event.start + event.length <= charCount) { - try { - inserted = txt.getTextRange(event.start, event.length); - } catch (Exception ex) { - System.err.println("EditorComposite.modifyText: getTextRange failed — start=" - + event.start + " length=" + event.length + " charCount=" + charCount - + "; fold shifts may be wrong: " + ex); - } - } else if (event.length > 0) { - System.err.println("EditorComposite.modifyText: event offsets out of range — start=" - + event.start + " length=" + event.length + " charCount=" + charCount - + "; fold shifts may be wrong"); - } - int removedNL = 0, addedNL = 0; - String removed = event.replacedText == null ? "" : event.replacedText; - for (int i = 0; i < removed.length(); i++) if (removed.charAt(i) == '\n') removedNL++; - for (int i = 0; i < inserted.length(); i++) if (inserted.charAt(i) == '\n') addedNL++; - int delta = addedNL - removedNL; - if (delta != 0) { - int editStartLine = txt.getLineAtOffset(event.start); - folding.shiftFoldsBelow(editStartLine, delta); - } - } - if (folding != null && !folding.isInFoldOp()) { - folding.recomputeRanges(); - } + // Fold-headerLine shifts and foldable-range recomputation are + // owned by FoldingManager's own modifyText listener (registered + // first, runs ahead of SyntaxHighlighter so parser.textModified + // sees the post-shift state when reading canonical source). modified = true; updateModified(); @@ -1015,21 +989,23 @@ else if( e.stateMask == (SWT.CTRL | SWT.SHIFT) ) { switch(e.keyCode) { case 'p': case 'P': - if(startBlockToken != null && endBlockToken != null){ + if(startBlockToken != null && endBlockToken != null){ try { - int setPos = 0; + // Compare caret (view) with token offsets (model) + // in a single coord space, then jump via + // gotoModelOffset so any covering fold expands. StyledText newTxt = tempEditor.getStyledText(); - if(newTxt.getCaretOffset() >= startBlockToken.getStart() && - newTxt.getCaretOffset() <= startBlockToken.getEnd()) - setPos = endBlockToken.getEnd(); + int caretModel = (folding != null) + ? folding.viewToModel(newTxt.getCaretOffset()) + : newTxt.getCaretOffset(); + int setPosModel; + if (caretModel >= startBlockToken.getStart() + && caretModel <= startBlockToken.getEnd()) + setPosModel = endBlockToken.getEnd(); else - setPos = startBlockToken.getStart(); - - //newTxt.setCaretOffset(txt.getText().length()); - //newTxt.showSelection(); - newTxt.setCaretOffset(setPos); - tempEditor.handleCaretChange(); - newTxt.showSelection(); + setPosModel = startBlockToken.getStart(); + + tempEditor.gotoModelOffset(setPosModel); tempEditor.lineHighlight(); } catch (IllegalArgumentException ex) { // Just ignore it @@ -1049,12 +1025,14 @@ else if( e.stateMask == (SWT.CTRL | SWT.SHIFT) ) { case 'd': case 'D': String sTmpString=txt.getSelectionText(); - if(sTmpString.length() == 0 && getTokenAt(txt.getCaretOffset()) != null){ - int iStart, iEnd; - iStart=getTokenAt(txt.getCaretOffset()).getStart(); - iEnd=getTokenAt(txt.getCaretOffset()).getEnd(); - txt.setSelection(iStart, iEnd); - sTmpString= txt.getSelectionText(); + if(sTmpString.length() == 0) { + Token tok = getTokenAt(txt.getCaretOffset()); + int iStart = tokenStartView(tok); + int iEnd = tokenEndView(tok); + if (iStart >= 0 && iEnd >= 0) { + txt.setSelection(iStart, iEnd); + sTmpString = txt.getSelectionText(); + } } if(isAlphaNumeric(sTmpString)) defineVarShell(sTmpString); @@ -1314,12 +1292,14 @@ public void widgetSelected(SelectionEvent e) { defineVar.addSelectionListener(new SelectionAdapter() { public void widgetSelected(SelectionEvent e) { String sTmpString=txt.getSelectionText(); - if(sTmpString.length() == 0 && getTokenAt(txt.getCaretOffset()) != null){ - int iStart, iEnd; - iStart=getTokenAt(txt.getCaretOffset()).getStart(); - iEnd=getTokenAt(txt.getCaretOffset()).getEnd(); - txt.setSelection(iStart, iEnd); - sTmpString= txt.getSelectionText(); + if(sTmpString.length() == 0) { + Token tok = getTokenAt(txt.getCaretOffset()); + int iStart = tokenStartView(tok); + int iEnd = tokenEndView(tok); + if (iStart >= 0 && iEnd >= 0) { + txt.setSelection(iStart, iEnd); + sTmpString = txt.getSelectionText(); + } } if(isAlphaNumeric(sTmpString)) defineVarShell(sTmpString); @@ -1681,18 +1661,13 @@ private Boolean matchVarAndGoto(Variable var, String varToMatch){ editor = (EditorComposite) o; // if (token.getStart() >= 0 && editor != null) { // editor.getStyledText().setTopIndex(Math.max(0, task.getLine() - 10)); - try { - StyledText newTxt = editor.getStyledText(); - newTxt.setCaretOffset(newTxt.getText().length()); - newTxt.showSelection(); - newTxt.setCaretOffset(var.getPos()); - editor.handleCaretChange(); - // Drop Navigation Position + // var.getPos() is a model offset in the target editor's + // canonical source. Use the target editor's gotoModelOffset so + // any fold covering the declaration auto-expands before the + // caret moves. + if (editor.gotoModelOffset(var.getPos())) { RepDevMain.mainShell.addToNavHistory(file, txt.getLineAtOffset(txt.getCaretOffset())); - newTxt.showSelection(); editor.lineHighlight(); - } catch (IllegalArgumentException ex) { - // Just ignore it } editor.getStyledText().setFocus(); return true; @@ -1724,21 +1699,15 @@ private Boolean matchTokenAndGoto(Token token, String key, String nameToMAtch){ editor = (EditorComposite) o; // if (token.getStart() >= 0 && editor != null) { // editor.getStyledText().setTopIndex(Math.max(0, task.getLine() - 10)); + // token.getBefore().getStart() is a model offset in the target + // editor's canonical source. try { - StyledText newTxt = editor.getStyledText(); - newTxt.setCaretOffset(newTxt.getText().length()); - newTxt.showSelection(); - newTxt.setCaretOffset(token.getBefore().getStart()); - editor.handleCaretChange(); - // Drop Navigation Position - RepDevMain.mainShell.addToNavHistory(file, txt.getLineAtOffset(txt.getCaretOffset())); - newTxt.showSelection(); - editor.lineHighlight(); - } catch (IllegalArgumentException ex) { - // Just ignore it + if (editor.gotoModelOffset(token.getBefore().getStart())) { + RepDevMain.mainShell.addToNavHistory(file, txt.getLineAtOffset(txt.getCaretOffset())); + editor.lineHighlight(); + } } catch (NullPointerException ex) { return false; - // Just ignore it } editor.getStyledText().setFocus(); return true; @@ -1759,15 +1728,20 @@ public void defineVariable(String sStr){ int iEndPos, iCurPos; if(sec.exist("define")){ - // Get the insertion point in the DEFINE section. + // Get the insertion point in the DEFINE section. Section offsets + // are model coords; translate (auto-expanding if DEFINE is folded) + // before driving the StyledText caret. iEndPos = sec.getLastInsertPos("define"); + if (folding != null) folding.ensureModelOffsetVisible(iEndPos); + int viewEndPos = (folding != null) ? folding.modelToView(iEndPos) : iEndPos; + if (viewEndPos < 0) return; // Save the current position of the cursor so that we can return to this // point after the variable has been inserted. iCurPos = txt.getCaretOffset(); // Move the cursor to the insertion point and insert the variable definition. - - try{ - txt.setCaretOffset(iEndPos); + + try{ + txt.setCaretOffset(viewEndPos); txt.insert(sStr+"\n"); // if the original cursor is after the define section, then recalculate the // new cursor position. @@ -1810,7 +1784,12 @@ public void defineVarShell(String varName){ * in. */ public void gotoSectionShell(){ - GotoSectionShell.create(this, txt.getCaretOffset()); + // SectionInfo positions are model coords; the goto shell compares the + // caret against them to preselect the current section. Translate the + // view-coord caret offset at the call site so the shell stays a pure + // model-coord consumer. + int caretModel = (folding != null) ? folding.viewToModel(txt.getCaretOffset()) : txt.getCaretOffset(); + GotoSectionShell.create(this, caretModel); } /** @@ -1827,15 +1806,13 @@ public ArrayList getSectionInfoList(){ */ public void gotoSection(String section){ if(!section.equals("") && sec.exist(section)){ - txt.setCaretOffset(txt.getText().length()); - txt.showSelection(); - txt.setCaretOffset(sec.getPos(section)); - handleCaretChange(); - // Drop Navigation Position - RepDevMain.mainShell.addToNavHistory(file, txt.getLineAtOffset(txt.getCaretOffset())); - txt.showSelection(); - lineHighlight(); - + // sec.getPos is a model offset (section positions are computed + // from canonical source). gotoModelOffset auto-expands any fold + // covering the section header and translates to view coords. + if (gotoModelOffset(sec.getPos(section))) { + RepDevMain.mainShell.addToNavHistory(file, txt.getLineAtOffset(txt.getCaretOffset())); + lineHighlight(); + } } } @@ -1923,10 +1900,15 @@ public boolean isNum(String str){ * @param offset * @return Token */ - public Token getTokenAt(int offset){ + public Token getTokenAt(int viewOffset){ int beforeTokenEnd = 0; Token token = null; + // Token offsets are model coords; the caller hands us a view offset + // (typically txt.getCaretOffset()). Translate once at the boundary so + // the inner comparisons line up. + int offset = (folding != null) ? folding.viewToModel(viewOffset) : viewOffset; + for(Token tok : parser.getLtokens()){ if(offset == 0){ token = tok; @@ -1942,6 +1924,71 @@ else if(tok != null && tok.getBefore() != null){ return token; } + /** + * View-coord start of a token whose stored offset is in model coords. + * Returns -1 when the token sits inside a currently-collapsed fold. + * Callers that need to feed the value to {@code StyledText.setSelection} + * or {@code redrawRange} must guard against -1. + */ + public int tokenStartView(Token tok) { + if (tok == null) return -1; + return (folding != null) ? folding.modelToView(tok.getStart()) : tok.getStart(); + } + + /** Companion to {@link #tokenStartView} for the token's end offset. */ + public int tokenEndView(Token tok) { + if (tok == null) return -1; + return (folding != null) ? folding.modelToView(tok.getEnd()) : tok.getEnd(); + } + + /** + * Move the caret to a model offset (e.g. a variable's declaration site, + * a section header, an error line). Auto-expands any enclosing fold so + * the target is visible. Returns {@code true} if the caret was placed, + * {@code false} if the offset is out of range. + */ + public boolean gotoModelOffset(int modelOffset) { + if (modelOffset < 0) return false; + if (folding != null) { + folding.ensureModelOffsetVisible(modelOffset); + } + int viewOffset = (folding != null) ? folding.modelToView(modelOffset) : modelOffset; + if (viewOffset < 0) return false; + int max = txt.getCharCount(); + if (viewOffset > max) viewOffset = max; + try { + txt.setCaretOffset(viewOffset); + handleCaretChange(); + txt.showSelection(); + } catch (IllegalArgumentException ex) { + return false; + } + return true; + } + + /** + * Move the caret to a zero-based model line (column 1). Auto-expands any + * enclosing fold. Returns {@code true} on success. + */ + public boolean gotoModelLine(int modelLine, int col) { + if (folding != null) folding.ensureModelLineVisible(modelLine); + int viewLine = (folding != null) ? folding.modelLineToViewLine(modelLine) : modelLine; + if (viewLine < 0) return false; + int max = Math.max(0, txt.getLineCount() - 1); + if (viewLine > max) viewLine = max; + try { + int offset = txt.getOffsetAtLine(viewLine) + Math.max(0, col); + int charCount = txt.getCharCount(); + if (offset > charCount) offset = charCount; + txt.setCaretOffset(offset); + handleCaretChange(); + txt.showSelection(); + } catch (IllegalArgumentException ex) { + return false; + } + return true; + } + // Bruce - End /** @@ -2166,11 +2213,13 @@ else if( currentEditVarPos != -1 && txt.getCaretOffset() > snippetStartPos + cur tokens = parser.getLtokens(); - //Find your current token + //Find your current token. Token offsets are model coords; translate + // the view-coord caret offset to model before comparing. + int caretModel = (folding != null) ? folding.viewToModel(txt.getCaretOffset()) : txt.getCaretOffset(); for( Token tok: tokens ) { tokloc++; - if(txt.getCaretOffset() >= tok.getStart() && txt.getCaretOffset() <= tok.getEnd()) { + if(caretModel >= tok.getStart() && caretModel <= tok.getEnd()) { cur = tok; break; } @@ -2185,9 +2234,10 @@ else if( currentEditVarPos != -1 && txt.getCaretOffset() > snippetStartPos + cur } } - // Refresh the Section Info + // Refresh the Section Info. Tokens are model-coord; feed the canonical + // source so positions stay valid when folds hide a section body. if(prevTxtLine != txt.getLineAtOffset(txt.getCaretOffset())){ - sec.refreshList(tokens, txt.getText()); + sec.refreshList(tokens, parser.canonicalSourceText()); prevTxtLine = txt.getLineAtOffset(txt.getCaretOffset()); } @@ -2295,9 +2345,12 @@ else if( tokens.get(tokloc).isHead() && } } - //IF we need to update, only call this once - for( Token tok : redrawTokens ) - txt.redrawRange(tok.getStart(),tok.getStr().length(),false); + //IF we need to update, only call this once. Token offsets are model + // coords; redrawRange needs view coords — skip when hidden. + for( Token tok : redrawTokens ) { + int v = tokenStartView(tok); + if (v >= 0) txt.redrawRange(v, tok.getStr().length(), false); + } } private void clearSnippetMode() { @@ -2309,7 +2362,8 @@ private void clearSnippetMode() { tok.setBackgroundReason(Token.SpecialBackgroundReason.NONE); tok.setSpecialBackground(null); tok.setCurrentVar(null); - txt.redrawRange(tok.getStart(),tok.getStr().length(),false); + int v = tokenStartView(tok); + if (v >= 0) txt.redrawRange(v, tok.getStr().length(), false); } } @@ -2420,7 +2474,13 @@ private void updateSnippet(){ for( Token tok : parser.getLtokens()){ - if( tok.getStart() >= (list.get(x) + snippetStartPos) && tok.getEnd() <= (snippetStartPos + list.get(x) + list.get(x+1))){ + // snippetStartPos + list offsets are view coords; token + // offsets are model. Compare in view coords by translating + // the token endpoints (skip tokens inside a fold). + int tokViewStart = tokenStartView(tok); + int tokViewEnd = tokenEndView(tok); + if (tokViewStart < 0 || tokViewEnd < 0) continue; + if( tokViewStart >= (list.get(x) + snippetStartPos) && tokViewEnd <= (snippetStartPos + list.get(x) + list.get(x+1))){ if( i == currentEditVarPos ){ tok.setSpecialBackground(SNIPPET_VAR_CURRENT); @@ -2432,7 +2492,7 @@ private void updateSnippet(){ tok.setBackgroundReason(Token.SpecialBackgroundReason.CODE_SNIPPET); tok.setCurrentVar(currentSnippet.getVar(i)); - txt.redrawRange(tok.getStart(),tok.getStr().length(),false); + txt.redrawRange(tokViewStart, tok.getStr().length(), false); } } } diff --git a/com/repdev/FoldingManager.java b/com/repdev/FoldingManager.java index a067f21..85e2a49 100644 --- a/com/repdev/FoldingManager.java +++ b/com/repdev/FoldingManager.java @@ -25,6 +25,8 @@ import java.util.HashSet; import java.util.Stack; +import org.eclipse.swt.custom.ExtendedModifyEvent; +import org.eclipse.swt.custom.ExtendedModifyListener; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.events.MouseAdapter; import org.eclipse.swt.events.MouseEvent; @@ -122,6 +124,17 @@ public FoldingManager(EditorComposite editor, StyledText txt, RepgenParser parse } private void install() { + // Registered first so headerLine shifts complete before downstream + // listeners (SyntaxHighlighter -> RepgenParser.textModified) run. + // Without this ordering, parser-side canonical-source reassembly sees + // stale headerLine values for one edit cycle and reinserts hidden + // blocks at the wrong offset — corrupting model-coord parse state. + txt.addExtendedModifyListener(new ExtendedModifyListener() { + public void modifyText(ExtendedModifyEvent event) { + onTextModified(event); + } + }); + txt.addPaintListener(new PaintListener() { public void paintControl(PaintEvent e) { paintMarkers(e); @@ -142,6 +155,58 @@ public void mouseDown(MouseEvent e) { }); } + /** + * Apply fold-state updates triggered by user edits to the visible buffer. + * Two concerns: + *

    + *
  1. {@link #shiftFoldsBelow}: edits above a fold change the view line + * of its header; the {@link FoldRegion#headerLine} stored on each + * collapsed entry must follow.
  2. + *
  3. {@link #recomputeRanges}: edits may add/remove foldable head/end + * pairs, so the cached foldable list needs rebuilding.
  4. + *
+ * Skipped during in-progress fold operations — those manage their own + * shifts and rebuilds. + */ + private void onTextModified(ExtendedModifyEvent event) { + if (inFoldOp) return; + + if (hasActiveFolds()) { + String inserted = ""; + int charCount = txt.getCharCount(); + if (event.length > 0 && event.start >= 0 && event.start + event.length <= charCount) { + try { + inserted = txt.getTextRange(event.start, event.length); + } catch (Exception ex) { + // Bounds-check above should preclude this; log so an + // upstream miscount surfaces instead of silently leaving + // fold headerLines wrong (which later drops fold bodies + // at EOF, see offsetAtLineStart diagnostic). + System.err.println("FoldingManager.onTextModified: getTextRange failed — start=" + + event.start + " length=" + event.length + " charCount=" + charCount + + "; fold shifts may be wrong: " + ex); + } + } else if (event.length > 0) { + System.err.println("FoldingManager.onTextModified: event offsets out of range — start=" + + event.start + " length=" + event.length + " charCount=" + charCount + + "; fold shifts may be wrong"); + } + int removedNL = 0, addedNL = 0; + String removed = event.replacedText == null ? "" : event.replacedText; + for (int i = 0; i < removed.length(); i++) if (removed.charAt(i) == '\n') removedNL++; + for (int i = 0; i < inserted.length(); i++) if (inserted.charAt(i) == '\n') addedNL++; + int delta = addedNL - removedNL; + if (delta != 0) { + int editStartLine; + try { editStartLine = txt.getLineAtOffset(event.start); } + catch (IllegalArgumentException ex) { editStartLine = -1; } + if (editStartLine >= 0) shiftFoldsBelow(editStartLine, delta); + } + } + + recomputeRanges(); + } + public int getExtraGutterWidth() { return FOLD_COLUMN_WIDTH; } public boolean isInFoldOp() { return inFoldOp; } @@ -258,11 +323,17 @@ public void recomputeRanges() { for (int i = 0; i < tokens.size(); i++) { Token t = tokens.get(i); String s = t.getStr(); + // Token offsets are model (canonical-source) coords. Foldable + // ranges are expressed in view coords because they drive UI + // (gutter, click-to-fold) and the buffer slice on collapse. Tokens + // whose model offset maps to -1 are inside an existing fold — + // skip them; they belong to the outer fold's hiddenText and + // shouldn't generate a new foldable range. // Only consider block-level head/end pairs: skip string/date/paren openers // which produce noisy single-line folds. if (t.isRealHead() && !"\"".equals(s) && !"'".equals(s) && !"(".equals(s) && !":(".equals(s)) { - int tStart = t.getStart(); + int tStart = modelToView(t.getStart()); if (tStart < 0 || tStart >= charCount) continue; int tLine; try { tLine = txt.getLineAtOffset(tStart); } @@ -271,7 +342,7 @@ public void recomputeRanges() { stack.push(i); } else if (t.isRealEnd() && !")".equals(s) && !"\"".equals(s) && !"'".equals(s)) { if ("]".equals(s) && !orphanCloserLines.isEmpty()) { - int tStart = t.getStart(); + int tStart = modelToView(t.getStart()); if (tStart >= 0 && tStart < charCount) { try { int tLine = txt.getLineAtOffset(tStart); @@ -282,8 +353,8 @@ public void recomputeRanges() { if (!stack.isEmpty()) { int headIdx = stack.pop(); Token head = tokens.get(headIdx); - int hStart = head.getStart(); - int eStart = t.getStart(); + int hStart = modelToView(head.getStart()); + int eStart = modelToView(t.getStart()); if (hStart < 0 || eStart < 0 || hStart >= charCount || eStart > charCount) continue; try { int hl = txt.getLineAtOffset(hStart); @@ -638,6 +709,164 @@ private static int countNewlines(String s) { return n; } + // ------------------------------------------------------------------ + // Model/view coordinate translation. + // + // Folded regions are physically excised from the StyledText buffer. The + // canonical (unfolded) source — returned by {@link #getUnfoldedText()} / + // {@link #getFullSourceText()} — is the model; the StyledText buffer is a + // projection that hides those segments. + // + // Two distinct offset spaces: + // * model offset: index into the unfolded source. + // * view offset: index into the live StyledText buffer. + // Parser data structures (tokens, vars, errors) live in model coords so + // they stay correct across fold/expand. UI consumers (caret placement, + // styling, gutter) live in view coords. Translation only happens at the + // boundary between the two — that's the entire purpose of this API. + // + // Implementation note: folds are stored sorted-on-collapse and walked in + // view-headerLine order. For each fold, its hidden text sits in the model + // at view-offset(headerLine+1) plus the cumulative length of all earlier + // folds' hidden text. The two methods below are exact inverses of each + // other on visible offsets, and modelToView returns -1 on hidden offsets. + // ------------------------------------------------------------------ + + private ArrayList foldedSortedByHeader() { + ArrayList sorted = new ArrayList(folded); + Collections.sort(sorted, new Comparator() { + public int compare(FoldRegion a, FoldRegion b) { return a.headerLine - b.headerLine; } + }); + return sorted; + } + + /** + * Translate a view offset (index into the live buffer) to a model offset + * (index into the unfolded source). Inverse of {@link #modelToView(int)} + * on visible offsets. + */ + public int viewToModel(int viewOffset) { + if (folded.isEmpty()) return viewOffset; + int modelOffset = viewOffset; + ArrayList sorted = foldedSortedByHeader(); + for (int i = 0; i < sorted.size(); i++) { + FoldRegion fr = sorted.get(i); + int hiddenStartView; + try { hiddenStartView = txt.getOffsetAtLine(fr.headerLine + 1); } + catch (IllegalArgumentException ex) { continue; } + if (viewOffset >= hiddenStartView) modelOffset += fr.hiddenText.length(); + } + return modelOffset; + } + + /** + * Translate a model offset to a view offset, or {@code -1} if the model + * offset lies inside a currently-folded region (not visible). + */ + public int modelToView(int modelOffset) { + if (folded.isEmpty()) return modelOffset; + ArrayList sorted = foldedSortedByHeader(); + int accum = 0; + for (int i = 0; i < sorted.size(); i++) { + FoldRegion fr = sorted.get(i); + int hiddenStartView; + try { hiddenStartView = txt.getOffsetAtLine(fr.headerLine + 1); } + catch (IllegalArgumentException ex) { continue; } + int hiddenStartModel = hiddenStartView + accum; + int hiddenEndModel = hiddenStartModel + fr.hiddenText.length(); + if (modelOffset >= hiddenStartModel && modelOffset < hiddenEndModel) return -1; + if (modelOffset >= hiddenEndModel) accum += fr.hiddenText.length(); + } + return modelOffset - accum; + } + + /** True iff the given model offset is currently visible (not inside a fold). */ + public boolean isModelOffsetVisible(int modelOffset) { + return modelToView(modelOffset) >= 0; + } + + /** + * Translate a zero-based model line number to a zero-based view line + * number, or {@code -1} if the line is hidden by a fold. + */ + public int modelLineToViewLine(int modelLine) { + if (folded.isEmpty()) return modelLine; + ArrayList sorted = foldedSortedByHeader(); + int viewLine = modelLine; + for (int i = 0; i < sorted.size(); i++) { + FoldRegion fr = sorted.get(i); + int hiddenStartModelLine = fr.headerLine + 1; + // Cumulative folded-line offset before this fold is what shifts + // model-line numbering up to this point — but headerLine is + // already in view coords, so the hidden lines below it haven't + // yet been counted. Add them now if our modelLine is past them. + int hiddenLines = countNewlines(fr.hiddenText); + // hiddenStartModelLine is in *current view-line-then-shifted* terms. + // The model line that corresponds to view headerLine+1 = headerLine+1 + (sum of hidden lines from folds with smaller headerLine). + int hiddenStartModelLineAbs = hiddenStartModelLine; + for (int j = 0; j < i; j++) hiddenStartModelLineAbs += countNewlines(sorted.get(j).hiddenText); + if (modelLine >= hiddenStartModelLineAbs && modelLine < hiddenStartModelLineAbs + hiddenLines) return -1; + if (modelLine >= hiddenStartModelLineAbs + hiddenLines) viewLine -= hiddenLines; + } + return viewLine; + } + + /** Translate a zero-based view line number to a zero-based model line number. */ + public int viewLineToModelLine(int viewLine) { + if (folded.isEmpty()) return viewLine; + ArrayList sorted = foldedSortedByHeader(); + int modelLine = viewLine; + for (int i = 0; i < sorted.size(); i++) { + FoldRegion fr = sorted.get(i); + if (fr.headerLine < viewLine) modelLine += countNewlines(fr.hiddenText); + } + return modelLine; + } + + /** + * Expand any folds whose hidden range covers the given model offset, so + * subsequent {@link #modelToView(int)} returns a visible offset. No-op if + * the offset is already visible. Used by goto-definition / error-row + * navigation when the target sits inside a collapsed region. + */ + public void ensureModelOffsetVisible(int modelOffset) { + if (folded.isEmpty()) return; + // Iterate over a snapshot — expand() mutates `folded`. + ArrayList snapshot = foldedSortedByHeader(); + int accum = 0; + for (int i = 0; i < snapshot.size(); i++) { + FoldRegion fr = snapshot.get(i); + int hiddenStartView; + try { hiddenStartView = txt.getOffsetAtLine(fr.headerLine + 1); } + catch (IllegalArgumentException ex) { continue; } + int hiddenStartModel = hiddenStartView + accum; + int hiddenEndModel = hiddenStartModel + fr.hiddenText.length(); + if (modelOffset >= hiddenStartModel && modelOffset < hiddenEndModel) { + expand(fr); + return; + } + if (modelOffset >= hiddenEndModel) accum += fr.hiddenText.length(); + } + } + + /** Convenience: like {@link #ensureModelOffsetVisible} for a model line. */ + public void ensureModelLineVisible(int modelLine) { + ensureModelOffsetVisible(offsetOfModelLineStart(modelLine)); + } + + private int offsetOfModelLineStart(int modelLine) { + String s = getUnfoldedText(); + if (modelLine <= 0) return 0; + int found = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == '\n') { + found++; + if (found == modelLine) return i + 1; + } + } + return s.length(); + } + static int displayLineNumberFor(int visibleLine, ArrayList foldedRegions) { int line = visibleLine + 1; for (int i = 0; i < foldedRegions.size(); i++) { diff --git a/com/repdev/MainShell.java b/com/repdev/MainShell.java index 3b2d112..a580132 100644 --- a/com/repdev/MainShell.java +++ b/com/repdev/MainShell.java @@ -3032,20 +3032,16 @@ public void widgetDefaultSelected(SelectionEvent e) { editor = (EditorComposite) o; if (error.getLine() >= 0 && editor != null) { - editor.getStyledText().setTopIndex(Math.max(0, error.getLine() - 10)); - - try { - editor.getStyledText().setCaretOffset( - Math.min(editor.getStyledText().getOffsetAtLine(Math.max(0, error.getLine() - 1)) + Math.max(0, error.getCol() - 1), editor.getStyledText() - .getCharCount() - 1)); - editor.handleCaretChange(); + // Error line/col are model coords (1-based, as returned by + // Symitar / lineColAt). gotoModelLine takes 0-based model + // line/col and auto-expands any enclosing fold so the + // error site is visible even when its section is collapsed. + int modelLine = Math.max(0, error.getLine() - 1); + int modelCol = Math.max(0, error.getCol() - 1); + if (editor.gotoModelLine(modelLine, modelCol)) { editor.lineHighlight(); - // Drop Navigation Position - addToNavHistory(editor.getFile(), editor.getStyledText().getLineAtOffset(editor.getStyledText().getCaretOffset())); - } catch (IllegalArgumentException ex) { - // Just ignore it + addToNavHistory(editor.getFile(), editor.getStyledText().getLineAtOffset(editor.getStyledText().getCaretOffset())); } - editor.getStyledText().setFocus(); } } @@ -3074,20 +3070,17 @@ public void widgetDefaultSelected(SelectionEvent e) { editor = (EditorComposite) o; if (task.getLine() >= 0 && editor != null) { - editor.getStyledText().setTopIndex(Math.max(0, task.getLine() - 10)); - - try { - editor.getStyledText().setCaretOffset( - Math.min(editor.getStyledText().getOffsetAtLine(Math.max(0, task.getLine() - 1)) + Math.max(0, task.getCol() - 1), editor.getStyledText() - .getCharCount() - 1)); - editor.handleCaretChange(); + // Task line/col are 0-based model coords (set by the task + // scanner via lineColAt). gotoModelLine handles fold + // expansion + view translation; preserving the historical + // MainShell semantic where task.getLine() is treated as + // 1-based for navigation by subtracting 1. + int modelLine = Math.max(0, task.getLine() - 1); + int modelCol = Math.max(0, task.getCol() - 1); + if (editor.gotoModelLine(modelLine, modelCol)) { editor.lineHighlight(); - // Drop Navigation Position addToNavHistory(editor.getFile(), editor.getStyledText().getLineAtOffset(editor.getStyledText().getCaretOffset())); - } catch (IllegalArgumentException ex) { - // Just ignore it } - editor.getStyledText().setFocus(); } } diff --git a/com/repdev/SyntaxHighlighter.java b/com/repdev/SyntaxHighlighter.java index 3248b90..11c8400 100644 --- a/com/repdev/SyntaxHighlighter.java +++ b/com/repdev/SyntaxHighlighter.java @@ -37,6 +37,7 @@ import org.eclipse.swt.widgets.Display; import com.repdev.parser.FunctionLayout; +import com.repdev.parser.HiddenTextProvider; import com.repdev.parser.RepgenParser; import com.repdev.parser.Token; import com.repdev.parser.Variable; @@ -244,36 +245,47 @@ public void modifyText(ExtendedModifyEvent e) { } public StyleRange getStyle(Token tok) { + return getStyleAtView(tok, tok.getStart()); + } + + /** + * Build a {@link StyleRange} for {@code tok} anchored at the given view + * offset. The parser stores token offsets in model (unfolded) coords; + * StyledText needs view coords. Callers translate via + * {@link HiddenTextProvider#modelToView(int)} once per token and pass the + * result here. + */ + public StyleRange getStyleAtView(Token tok, int viewStart) { boolean isVar = false; StyleRange range = null; if (tok.getCDepth() != 0) { - range = COMMENTS.getRange(tok.getStart(), tok.length()); + range = COMMENTS.getRange(viewStart, tok.length()); for( String taskType: RepgenParser.taskTokens ) - if( tok.getStr().equals(taskType) && (tok.getAfter() != null && tok.getAfter().getStr().equals(":")) ) range = TASK.getRange(tok.getStart(), tok.length()); + if( tok.getStr().equals(taskType) && (tok.getAfter() != null && tok.getAfter().getStr().equals(":")) ) range = TASK.getRange(viewStart, tok.length()); } else if (tok.inString()) - range = TYPE_CHAR.getRange(tok.getStart(), tok.length()); + range = TYPE_CHAR.getRange(viewStart, tok.length()); else if (tok.inDate()) - range = TYPE_DATE.getRange(tok.getStart(), tok.length()); + range = TYPE_DATE.getRange(viewStart, tok.length()); // Validates the token is a Record before the colon else if (tok.getAfter() != null && tok.getAfter().getStr().equals(":")) { if (tok.dbRecordValid()) - range = STRUCT1.getRange(tok.getStart(), tok.length()); + range = STRUCT1.getRange(viewStart, tok.length()); else - range = STRUCT1_INVALID.getRange(tok.getStart(), tok.length()); + range = STRUCT1_INVALID.getRange(viewStart, tok.length()); // Validates the token is a Field or a Field without the Sub Field if the next token is :( } else if (tok.getBefore() != null && tok.getBefore().getStr().equals(":")) { if (tok.dbFieldValid(RepgenParser.getDb().getTreeRecords()) || (tok.dbFieldValidNoSubFld(RepgenParser.getDb().getTreeRecords()))) - range = STRUCT2.getRange(tok.getStart(), tok.length()); + range = STRUCT2.getRange(viewStart, tok.length()); else - range = STRUCT2_INVALID.getRange(tok.getStart(), tok.length()); + range = STRUCT2_INVALID.getRange(viewStart, tok.length()); } else if (FunctionLayout.getInstance().containsName(tok.getStr()) && tok.getAfter() != null && tok.getAfter().getStr().equals("(")) - range = FUNCTIONS.getRange(tok.getStart(), tok.length()); + range = FUNCTIONS.getRange(viewStart, tok.length()); else if (RepgenParser.getKeywords().contains(tok.getStr())) - range = KEYWORDS.getRange(tok.getStart(), tok.length()); + range = KEYWORDS.getRange(viewStart, tok.length()); else if (RepgenParser.getSpecialvars().contains(tok.getStr())) - range = VARIABLES.getRange(tok.getStart(), tok.length()); + range = VARIABLES.getRange(viewStart, tok.length()); for (int i = 0; i < parser.getLvars().size(); i++){ Variable var = parser.getLvars().get(i); @@ -282,9 +294,9 @@ else if (RepgenParser.getSpecialvars().contains(tok.getStr())) } if (range == null && isVar) - range = VARIABLES.getRange(tok.getStart(), tok.length()); + range = VARIABLES.getRange(viewStart, tok.length()); else if( range == null ){ - range = NORMAL.getRange(tok.getStart(), tok.length()); + range = NORMAL.getRange(viewStart, tok.length()); } if( tok.getSpecialBackground() != null) @@ -297,35 +309,57 @@ public void lineGetStyle(LineStyleEvent event) { ArrayList ltokens = parser.getLtokens(); ArrayList ranges = new ArrayList(); - int line = txt.getLineAtOffset(event.lineOffset); + // event.lineOffset is in view (StyledText) coords; tokens are in model + // coords. Translate the view-line range to model coords once, scan + // ltokens against that range, then translate each token's start back + // to view coords when building the StyleRange. + HiddenTextProvider htp = parser.getHiddenTextProvider(); + int viewLineStart = event.lineOffset; + int line = txt.getLineAtOffset(viewLineStart); + int viewLineEnd; + if (line + 1 < txt.getLineCount()) + viewLineEnd = txt.getOffsetAtLine(line + 1); + else + viewLineEnd = txt.getCharCount(); + + int modelLineStart = (htp != null) ? htp.viewToModel(viewLineStart) : viewLineStart; + int modelLineEnd = (htp != null) ? htp.viewToModel(viewLineEnd) : viewLineEnd; int ftoken; for (ftoken = 0; ftoken < ltokens.size(); ftoken++) - if (ltokens.get(ftoken).getEnd() >= event.lineOffset) + if (ltokens.get(ftoken).getEnd() >= modelLineStart) break; - int ltoken = ltokens.size(); - if (line + 1 < txt.getLineCount()) { - int pos = txt.getOffsetAtLine(line + 1); - - for (ltoken = ftoken; ltoken < ltokens.size(); ltoken++) - if (ltokens.get(ltoken).getStart() > pos) - break; - } + int ltoken; + for (ltoken = ftoken; ltoken < ltokens.size(); ltoken++) + if (ltokens.get(ltoken).getStart() > modelLineEnd) + break; for (int i = ftoken; i < ltoken; i++){ - StyleRange range = getStyle(ltokens.get(i)); + Token tok = ltokens.get(i); + int tokViewStart = (htp != null) ? htp.modelToView(tok.getStart()) : tok.getStart(); + // modelToView returns -1 for tokens inside a fold; skip — there's + // no visible glyph to style. + if (tokViewStart < 0) continue; + + StyleRange range = getStyleAtView(tok, tokViewStart); ranges.add(range); - //If we are a backgroudn highlighted token, and a CODE SNIPPET one, then connect the highlighting over whitespace between tokens - if( ltokens.get(i).getBackgroundReason() == SpecialBackgroundReason.CODE_SNIPPET ) - if( i + 1 < ltoken && ltokens.get(i+1).getBackgroundReason() == SpecialBackgroundReason.CODE_SNIPPET && ltokens.get(i+1).getSnippetVar() == ltokens.get(i).getSnippetVar()) - ranges.add(new StyleRange(ltokens.get(i).getEnd(),ltokens.get(i+1).getStart()-ltokens.get(i).getEnd(),null,ltokens.get(i).getSpecialBackground())); + // Connect the snippet background highlight over whitespace between + // adjacent CODE_SNIPPET tokens. Both endpoints must translate to + // view coords — if either falls inside a fold, skip the connector. + if (tok.getBackgroundReason() == SpecialBackgroundReason.CODE_SNIPPET + && i + 1 < ltoken + && ltokens.get(i+1).getBackgroundReason() == SpecialBackgroundReason.CODE_SNIPPET + && ltokens.get(i+1).getSnippetVar() == tok.getSnippetVar()) { + int tokViewEnd = (htp != null) ? htp.modelToView(tok.getEnd()) : tok.getEnd(); + int nextViewStart = (htp != null) ? htp.modelToView(ltokens.get(i+1).getStart()) : ltokens.get(i+1).getStart(); + if (tokViewEnd >= 0 && nextViewStart >= 0 && nextViewStart > tokViewEnd) + ranges.add(new StyleRange(tokViewEnd, nextViewStart - tokViewEnd, null, tok.getSpecialBackground())); + } } - StyleRange[] rangesArray = new StyleRange[ranges.size()]; - - event.styles = ranges.toArray(rangesArray); + event.styles = ranges.toArray(new StyleRange[ranges.size()]); } public void setCustomLines(int[] lines) diff --git a/com/repdev/parser/HiddenTextProvider.java b/com/repdev/parser/HiddenTextProvider.java index ac21f5c..5b2a043 100644 --- a/com/repdev/parser/HiddenTextProvider.java +++ b/com/repdev/parser/HiddenTextProvider.java @@ -50,4 +50,17 @@ public interface HiddenTextProvider { * always project the full source should always return non-null. */ String getFullSourceText(); + + /** + * Translate a view offset (index into the live {@code StyledText} buffer) + * to a model offset (index into the unfolded source). Implementations + * with no active projection return {@code viewOffset} unchanged. + */ + int viewToModel(int viewOffset); + + /** + * Translate a model offset to a view offset, or {@code -1} if the model + * offset lies inside a currently-hidden region. + */ + int modelToView(int modelOffset); } diff --git a/com/repdev/parser/RepgenParser.java b/com/repdev/parser/RepgenParser.java index ace01d9..b5ceb04 100644 --- a/com/repdev/parser/RepgenParser.java +++ b/com/repdev/parser/RepgenParser.java @@ -79,6 +79,10 @@ public void setHiddenTextProvider(HiddenTextProvider p) { this.hiddenTextProvider = p; } + public HiddenTextProvider getHiddenTextProvider() { + return hiddenTextProvider; + } + boolean initialIncludeParseNeeded = true; //This will make sure that we parse the includes at least once when the file is first opened boolean refreshIncludes = false; //The parser will keep track of changes as the file is edited, and if an include reparse is needed, this flag will be set. //Since include parsing is resource intensive, it's up to the rest of the code to decide when to parse these if needed. (Usually on file save) @@ -418,65 +422,63 @@ public void run() { } }); - display.syncExec(new Runnable() { - public void run() { - try { - for (final Token tok : ltokens) { - boolean isTask = false; - for( String task: taskTokens ) - if( tok.getStr().equals(task)) isTask = true; - - if ( tok.getCDepth() > 0 && isTask && ( tok.getAfter()!=null ) && tok.getAfter().getStr().equals(":")) { - int line = txt.getLineAtOffset(tok.getStart()); - int col = tok.getStart() - txt.getOffsetAtLine(line); - String desc = txt.getText(tok.getStart(), txt.getOffsetAtLine(line+1)-1); - - - desc = desc.trim(); - desc = desc.replaceAll("\\]$", ""); - - Task.Type type; - type = Task.Type.TODO; - if( tok.getStr().equals("fixme") ) { - type = Task.Type.FIXME; - } else if( tok.getStr().equals("bug") || tok.getStr().equals("bugbug") ) { - type = Task.Type.BUG; - } else if( tok.getStr().equals("wtf") ) { - type = Task.Type.WTF; - } else if( tok.getStr().equals("bm") || tok.getStr().equals("bookmark") ) { - type = Task.Type.BM; - } else if( tok.getStr().equals("test") ) { - type = Task.Type.TEST; - } else if( tok.getStr().equals("note") ) { - type = Task.Type.NOTE; - } - - - - /* Don't die if the item does not have a line following it... - * Taken from my #include "" double click code. - */ - int startOffset = tok.getStart(); - int pos1 = txt.getText().toString().indexOf("\n",startOffset + tok.getStr().length()) + 1; - int pos2 = txt.getText().toString().indexOf("]",startOffset + tok.getStr().length()) - 1; - int endOffset = (pos1 0 && isTask && ( tok.getAfter()!=null ) && tok.getAfter().getStr().equals(":")) { + int[] lc = lineColAt(canonicalSource, tok.getStart()); + int line = lc[0]; + int col = lc[1]; + + Task.Type type; + type = Task.Type.TODO; + if( tok.getStr().equals("fixme") ) { + type = Task.Type.FIXME; + } else if( tok.getStr().equals("bug") || tok.getStr().equals("bugbug") ) { + type = Task.Type.BUG; + } else if( tok.getStr().equals("wtf") ) { + type = Task.Type.WTF; + } else if( tok.getStr().equals("bm") || tok.getStr().equals("bookmark") ) { + type = Task.Type.BM; + } else if( tok.getStr().equals("test") ) { + type = Task.Type.TEST; + } else if( tok.getStr().equals("note") ) { + type = Task.Type.NOTE; } - } catch (Exception e) { - // TODO: handle exception (Places TC here to fix a crash when a repgen being parsed is closed) + + int startOffset = tok.getStart(); + int searchFrom = startOffset + tok.getStr().length(); + int newlinePos = canonicalSource.indexOf("\n", searchFrom); + int bracketPos = canonicalSource.indexOf("]", searchFrom) - 1; + int endOffset; + if (newlinePos == -1) newlinePos = canonicalSource.length(); + else newlinePos = newlinePos + 1; + if (bracketPos < 0) bracketPos = canonicalSource.length(); + endOffset = Math.min(newlinePos, bracketPos); + + String desc; + if( endOffset - 1 <= startOffset || endOffset > canonicalSource.length()) + desc = ""; + else + desc = canonicalSource.substring(startOffset, endOffset); + desc = desc.trim(); + desc = desc.replaceAll("\\]$", ""); + + Task task = new Task(file.getName(), desc, line, col, type); + taskList.add( task ); } } - }); + } catch (Exception e) { + // TODO: handle exception (Places TC here to fix a crash when a repgen being parsed is closed) + } // Update the tasks table display.asyncExec(new Runnable() { @@ -872,14 +874,29 @@ private synchronized boolean parse(String filename, String str, int start, int e else charEnd = str.length(); - if( txt != null) + if( txt != null) { + // charStart/charEnd are model offsets (parse runs against the + // canonical source). Translate to view offsets before driving + // the StyledText redraw; if either endpoint maps into a hidden + // region, fall back to a full redraw. + int viewStart = charStart; + int viewEnd = charEnd; + if (hiddenTextProvider != null) { + int vs = hiddenTextProvider.modelToView(charStart); + int ve = hiddenTextProvider.modelToView(charEnd); + if (vs < 0 || ve < 0) { redrawAll = true; } + else { viewStart = vs; viewEnd = ve; } + } + int viewMax = txt.getCharCount(); + if (viewStart > viewMax) viewStart = viewMax; + if (viewEnd > viewMax) viewEnd = viewMax; if( redrawAll ) - txt.redrawRange(0, txt.getCharCount(), false); + txt.redrawRange(0, viewMax, false); + else if( replacedText != null && replacedText.contains("\n") ) + txt.redrawRange(viewStart, viewMax-viewStart, false); else - if( replacedText != null && replacedText.contains("\n") ) - txt.redrawRange(charStart, txt.getCharCount()-charStart, false); - else - txt.redrawRange(charStart,charEnd-charStart,false); + txt.redrawRange(viewStart, viewEnd-viewStart, false); + } } return allDefs; @@ -1057,44 +1074,23 @@ private synchronized void rebuildVars(String fileName, String data, ArrayListIf no {@link HiddenTextProvider} is set, or if it reports the same - * text as the live buffer (no active folds), this is a passthrough to the - * existing visible-buffer rebuild — preserving prior behavior in the - * unfolded case and in compare-tab editors that don't fold. - * - *

When the canonical text differs from the buffer, a throwaway full - * parse builds a temporary token list whose positions are model - * offsets (i.e. offsets into the unfolded source). {@code rebuildVars} - * is then driven from those tokens. {@link #ltokens} itself stays anchored - * to the visible buffer so the syntax highlighter and fold-range - * computation keep working unchanged. + * Canonical (unfolded) source text for the current file. Returns the live + * {@code StyledText} buffer when no projection is in effect — at which + * point view and model offsets coincide. Always non-null. * - *

Side effect: {@link Variable#getPos()} for vars declared inside a - * folded region holds a model offset that exceeds {@code txt.getCharCount()}. - * Callers that pass {@code var.getPos()} to {@code StyledText.getLineAtOffset} - * must guard against that — see {@link BackgroundSymitarErrorChecker} for - * the pattern (compute line/col against the canonical source string instead - * of the live buffer). + *

This is the single source of truth for everything the parser + * produces ({@link #ltokens}, {@link #lvars}, error positions). All + * offsets stored in those structures are model offsets and may exceed + * {@code txt.getCharCount()} when folds are active. UI consumers must + * translate via {@link HiddenTextProvider#modelToView(int)} before + * indexing into {@code txt}. */ - private synchronized void rebuildVarsFromCanonicalSource(String fileName) { - String fullText = (hiddenTextProvider != null) ? hiddenTextProvider.getFullSourceText() : null; - String visibleText = txt.getText(); - if (fullText == null || fullText.equals(visibleText)) { - rebuildVars(fileName, visibleText, ltokens); - return; + public String canonicalSourceText() { + if (hiddenTextProvider != null) { + String full = hiddenTextProvider.getFullSourceText(); + if (full != null) return full; } - ArrayList fullTokens = new ArrayList(); - parse(fileName, fullText, 0, fullText.length() - 1, 0, null, - fullTokens, new ArrayList(), new ArrayList(), - new ArrayList(), null); - rebuildVars(fileName, fullText, fullTokens); + return txt.getText(); } /** @@ -1120,15 +1116,21 @@ static int[] lineColAt(String text, int offset) { public void textModified(int start, int length, String replacedText){ if (reparse) { - int st = start; + // Translate the StyledText event offsets (view coords) to model + // coords so the incremental parse extends ltokens against the + // canonical source. When no projection is active, the translation + // is a no-op and behavior matches the pre-folding code path. + int modelStart = (hiddenTextProvider != null) ? hiddenTextProvider.viewToModel(start) : start; + int st = modelStart; int end = st + length; int oldend = st + replacedText.length(); boolean rebuildVars = false; long time = System.currentTimeMillis(); + String canonical = canonicalSourceText(); try { - parse(file.getName(), txt.getText(), st, end, oldend, replacedText, ltokens, lasttokens, removedtokens, lvars, txt); + parse(file.getName(), canonical, st, end, oldend, replacedText, ltokens, lasttokens, removedtokens, lvars, txt); for( Token cur : lasttokens){ if( cur.inDefs() ) @@ -1182,7 +1184,7 @@ public void textModified(int start, int length, String replacedText){ if( rebuildVars ) - rebuildVarsFromCanonicalSource(file.getName()); + rebuildVars(file.getName(), canonical, ltokens); if( initialIncludeParseNeeded ){ parseIncludes(); @@ -1199,7 +1201,9 @@ public void textModified(int start, int length, String replacedText){ public void parseIncludes(){ if( includeParserWorker == null ){ - includeParserWorker = new BackgroundIncludeParser(txt.getText()); + // Use canonical source so #include directives inside folded + // regions still feed the include graph. + includeParserWorker = new BackgroundIncludeParser(canonicalSourceText()); refreshIncludes = false; includeParserWorker.start(); } @@ -1215,8 +1219,9 @@ public void errorCheck(){ public void reparseAll() { try { ltokens = new ArrayList(); - parse(file.getName(), txt.getText(), 0, txt.getCharCount() - 1, 0, null, ltokens, lasttokens, removedtokens, lvars, txt); - rebuildVarsFromCanonicalSource(file.getName()); + String canonical = canonicalSourceText(); + parse(file.getName(), canonical, 0, canonical.length() - 1, 0, null, ltokens, lasttokens, removedtokens, lvars, txt); + rebuildVars(file.getName(), canonical, ltokens); System.out.println("Reparsed"); } catch (Exception e) { System.err.println("Syntax Highlighter error!"); From ca8a8f45deb9ce68df7a6348b093a0d8dc923c31 Mon Sep 17 00:00:00 2001 From: rakasha681 Date: Wed, 13 May 2026 14:09:19 -0400 Subject: [PATCH 09/10] (feat) Implement defensive copying and bounds checking in BackgroundSectionParser to prevent out-of-bounds exceptions during background parsing, especially relevent on long files with sections folded while a user makes edits. --- .../parser/BackgroundSectionParser.java | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/com/repdev/parser/BackgroundSectionParser.java b/com/repdev/parser/BackgroundSectionParser.java index 8d51e09..7841e61 100644 --- a/com/repdev/parser/BackgroundSectionParser.java +++ b/com/repdev/parser/BackgroundSectionParser.java @@ -114,9 +114,17 @@ public synchronized ArrayList getList(){ * This is the exposed method for calling the background parser. */ public void refreshList(ArrayList token, String txt){ - this.token = token; + // Defensive copy: token positions are model offsets that index into + // {@code txt}. The caller hands us a live reference to the parser's + // ltokens list, which the parser keeps mutating from the UI thread. + // Without a snapshot the background parse can iterate tokens that + // describe a state more recent than the accompanying text, producing + // substring(start, end) calls with offsets past txt.length() — e.g. + // "Range [31710, 31719) out of bounds for length 30230" while the + // user edits a long file with sections collapsed. + this.token = (token != null) ? new ArrayList(token) : new ArrayList(); this.txt = txt; - + // Do not parse again if it is currently parsing if(!parsing){ parsing = true; @@ -177,14 +185,24 @@ else if(tok.isRealEnd()){ System.out.println(tok.getStr()+":"+curDepth); } curDepth=1; + // Defensive bounds checks: this thread reads tokens against a + // text snapshot, but Token offsets can briefly disagree with + // the snapshot when the parser races on the UI thread. Skip + // the section rather than crash with substring OOBE. + String title; if(tok.getStr().equals("procedure")){ - si.setTitle(txt.substring(tok.getAfter().getStart(),tok.getAfter().getEnd())); + Token after = tok.getAfter(); + if (after == null) continue; + title = safeSubstring(txt, after.getStart(), after.getEnd()); } else{ - si.setTitle(txt.substring(tok.getStart(),tok.getEnd())); + title = safeSubstring(txt, tok.getStart(), tok.getEnd()); } + if (title == null) continue; + si.setTitle(title); si.setPos(tok.getStart()); - si.setFirstInsertPos(txt.indexOf("\n", si.getPos())+1); + int nl = (si.getPos() >= 0 && si.getPos() <= txt.length()) ? txt.indexOf("\n", si.getPos()) : -1; + si.setFirstInsertPos(nl + 1); } else if(curDepth == 0){ if(tok.getStr().equals("end") && tok.isRealEnd()){ @@ -199,6 +217,17 @@ else if(curDepth == 0){ parsing = false; } + /** + * Substring helper that returns {@code null} instead of throwing when the + * requested range falls outside the snapshot. Used to absorb the brief + * window where this thread's token snapshot describes a parser state that + * has advanced past the accompanying text snapshot. + */ + private static String safeSubstring(String src, int start, int end) { + if (src == null || start < 0 || end < start || end > src.length()) return null; + return src.substring(start, end); + } + /** * This is an internal method that returns true if the specified is a section head. * @return boolean true/false From 404acd05b183bb92ceec519031bba9d0e02c2f43 Mon Sep 17 00:00:00 2001 From: rakasha681 Date: Wed, 13 May 2026 15:45:25 -0400 Subject: [PATCH 10/10] (feat) Enhance folding functionality by implementing onTokensUpdated callback in HiddenTextProvider and updating related classes to ensure accurate fold range computation after parsing edits. Fold-All now always unfolds all first so that it starts with a clean snapshot and never have stale ranges for manual folds/edits that shifted line positions since the last snapshot. --- com/repdev/EditorComposite.java | 26 ++++ com/repdev/FoldingManager.java | 159 ++++++++++++++-------- com/repdev/parser/HiddenTextProvider.java | 10 ++ com/repdev/parser/RepgenParser.java | 9 ++ 4 files changed, 151 insertions(+), 53 deletions(-) diff --git a/com/repdev/EditorComposite.java b/com/repdev/EditorComposite.java index 011f1a8..0560097 100644 --- a/com/repdev/EditorComposite.java +++ b/com/repdev/EditorComposite.java @@ -1424,6 +1424,32 @@ public void menuShown(MenuEvent e) { frmTxt.bottom = new FormAttachment(100); txt.setLayoutData(frmTxt); + // Force a full reparse + fold-range refresh now that the buffer is + // populated. The txt.setText(str) above does fire modifyText and the + // SyntaxHighlighter→parser→onTokensUpdated chain runs, but observed + // behavior on file open was an empty foldable list (no carets drawn) + // until the user typed something. Forcing a synchronous rebuild here + // removes any dependence on listener ordering and timing for the + // initial draw. + if (parser != null) { + parser.reparseAll(); + } + if (folding != null) { + folding.recomputeRanges(); + // Diagnostic confirmed `foldable` is populated at this point, but + // the synchronous txt.redraw() above fires before SWT has finished + // laying out the StyledText — paintMarkers reads getClientArea() + // height of 0 and skips every line. Defer a second redraw to the + // next event loop tick so the gutter paint sees real bounds. + final FoldingManager f = folding; + final StyledText t = txt; + t.getDisplay().asyncExec(new Runnable() { + public void run() { + if (!t.isDisposed()) t.redraw(); + } + }); + } + if( parser != null ) parser.errorCheck(); diff --git a/com/repdev/FoldingManager.java b/com/repdev/FoldingManager.java index 85e2a49..41a1e17 100644 --- a/com/repdev/FoldingManager.java +++ b/com/repdev/FoldingManager.java @@ -171,6 +171,13 @@ public void mouseDown(MouseEvent e) { private void onTextModified(ExtendedModifyEvent event) { if (inFoldOp) return; + // Fold-headerLine shifts MUST run before SyntaxHighlighter's listener + // fires parser.textModified — the parser reassembles canonical source + // from the post-edit view buffer plus folded segments, and that + // reassembly is only correct when each fold's headerLine reflects + // post-edit line numbering. Foldable-range recomputation, on the + // other hand, has to wait until *after* the parser has produced fresh + // tokens; that path lives in {@link #onTokensUpdated()} instead. if (hasActiveFolds()) { String inserted = ""; int charCount = txt.getCharCount(); @@ -203,7 +210,16 @@ private void onTextModified(ExtendedModifyEvent event) { if (editStartLine >= 0) shiftFoldsBelow(editStartLine, delta); } } + } + /** + * Parser callback: rebuild the foldable-range cache against the freshly + * updated token list. Suppressed during fold-batches (collapseAll / + * expandAll), which call {@code recomputeRanges} once at the end of the + * batch via their own path. + */ + public void onTokensUpdated() { + if (inFoldOp) return; recomputeRanges(); } @@ -341,26 +357,31 @@ public void recomputeRanges() { if (foldedHeaderLines.contains(tLine)) continue; // orphan head inside a collapsed region stack.push(i); } else if (t.isRealEnd() && !")".equals(s) && !"\"".equals(s) && !"'".equals(s)) { + // Translate first and skip ends inside a hidden region BEFORE + // touching the stack. Popping for a hidden end steals an entry + // from an outer block whose head we did push — the next real + // end then pairs with the wrong head, and an outer fold ends + // up matched to an inner END (e.g. a PRINT TITLE that spans + // 104-160 gets clipped to 104-152 because the manual fold's + // own END token inside the hidden body popped first). + int eStartView = modelToView(t.getStart()); + if (eStartView < 0 || eStartView >= charCount) continue; if ("]".equals(s) && !orphanCloserLines.isEmpty()) { - int tStart = modelToView(t.getStart()); - if (tStart >= 0 && tStart < charCount) { - try { - int tLine = txt.getLineAtOffset(tStart); - if (orphanCloserLines.contains(tLine)) continue; - } catch (IllegalArgumentException ignored) { } - } + try { + int tLine = txt.getLineAtOffset(eStartView); + if (orphanCloserLines.contains(tLine)) continue; + } catch (IllegalArgumentException ignored) { } } if (!stack.isEmpty()) { int headIdx = stack.pop(); Token head = tokens.get(headIdx); int hStart = modelToView(head.getStart()); - int eStart = modelToView(t.getStart()); - if (hStart < 0 || eStart < 0 || hStart >= charCount || eStart > charCount) continue; + if (hStart < 0 || hStart >= charCount) continue; try { int hl = txt.getLineAtOffset(hStart); - int el = txt.getLineAtOffset(eStart); + int el = txt.getLineAtOffset(eStartView); boolean isBracket = "[".equals(head.getStr()); - if (el - hl >= 1) foldable.add(new FoldableRange(hl, el, eStart, isBracket)); + if (el - hl >= 1) foldable.add(new FoldableRange(hl, el, eStartView, isBracket)); } catch (IllegalArgumentException ignored) { } } } @@ -426,6 +447,23 @@ public void expandAtCaret() { public void collapseAllSilent() { collapseAllInternal(false); } private void collapseAllInternal(boolean pushUndo) { + // Expand any pre-existing manual folds first so the snapshot ranges + // describe the fully-expanded view. Without this, snapshot.endLine + // reflects a T=0 view where some lines are hidden — by the time a + // later iteration processes the outermost block, those lines have + // been absorbed into a chain of batch-created folds and the snapshot + // value no longer matches the buffer, leaving the tail of the outer + // block unfolded (the "PRINT TITLE folded to line 155 instead of + // 160" symptom). The +preBatchLines accounting in collapseInternal + // only handles the simplest case (pre-batch fold directly nested in + // the snapshot range); chained absorption defeats it. Expanding up + // front sidesteps the bookkeeping entirely. Semantics are unchanged + // for fold-all: manual folds get absorbed into their enclosing + // top-level block exactly as before. + if (!folded.isEmpty()) { + expandAllInternal(false); + } + // Fold from bottom up so earlier line numbers stay stable during iteration. ArrayList ranges = new ArrayList(foldable); Collections.sort(ranges, new Comparator() { @@ -435,16 +473,10 @@ private void collapseAllInternal(boolean pushUndo) { batchMode = true; HashSet prevSnapshot = preBatchHiddenTexts; preBatchHiddenTexts = new HashSet(); - for (int i = 0; i < folded.size(); i++) preBatchHiddenTexts.add(folded.get(i).hiddenText); - // Anchor the t=0 coord system for this batch: every fold already in - // `folded` gets originalHeaderLine = its current headerLine. Batch- - // added folds will set originalHeaderLine from their snapshot range's - // headerLine, which is also a t=0 coord. That keeps nested-check math - // consistent regardless of shift accumulation. - for (int i = 0; i < folded.size(); i++) { - FoldRegion fr = folded.get(i); - folded.set(i, new FoldRegion(fr.headerLine, fr.hiddenText, fr.headerLine)); - } + // With pre-batch folds now fully expanded above, there are no + // pre-batch hiddenTexts to track; the snapshot below is exhaustive. + // Keep the field-allocation pattern so nested-expansion math elsewhere + // stays consistent. boolean any = false; try { for (int i = 0; i < ranges.size(); i++) { @@ -482,6 +514,14 @@ private void expandAllInternal(boolean pushUndo) { if (parser != null) parser.setReparse(false); txt.setRedraw(false); txt.replaceTextRange(0, txt.getCharCount(), expanded); + // Clear `folded` BEFORE the parser reparses. parser.reparseAll() + // reads canonical source via getUnfoldedText(), which re-inserts + // every entry in `folded` into the buffer text. If the entries + // linger past the replaceTextRange (which already wrote the full + // expanded text), canonical comes back with hidden segments + // duplicated — token offsets then drift past buffer length and + // every downstream view-coord operation degrades into garbage. + folded.clear(); } finally { txt.setRedraw(true); if (parser != null) { @@ -491,7 +531,6 @@ private void expandAllInternal(boolean pushUndo) { inFoldOp = false; } - folded.clear(); recomputeRanges(); if (pushUndo) editor.pushFoldUndo(EditorComposite.FOLD_OP_EXPAND_ALL, -1); } @@ -589,6 +628,37 @@ private void collapseInternal(FoldableRange range, boolean pushUndo) { if (parser != null) parser.setReparse(false); txt.setRedraw(false); txt.replaceTextRange(sliceStart, sliceEnd - sliceStart, ""); + + // Bring `folded` to its post-collapse shape BEFORE parser.reparseAll(). + // parser.reparseAll() reads canonical source via getUnfoldedText(), + // which combines the live buffer with every entry in `folded` — + // each entry inserted at its (current) headerLine. Two updates + // have to land first or canonical comes back wrong: + // 1. Shift downstream entries up by the removed line count, so + // their headerLines still point to the right view rows. + // 2. Append the just-collapsed region, so its hidden body is + // present in canonical (the buffer has lost those lines). + // Without this, every reparseAll mid-collapse produced model + // offsets that didn't match the buffer state the UI sees, which + // surfaced as fold-all mis-collapsing, garbled highlighting, and + // later offsetAtLineStart "requested line N past EOF" warnings. + int removedLines = linesBefore - txt.getLineCount(); + int hiddenNL = countNewlines(hidden); + if (hiddenNL != removedLines) { + System.err.println("FoldingManager.collapse: removedLines/hiddenNL mismatch — removed=" + removedLines + + " hiddenNL=" + hiddenNL + " header=" + range.headerLine + " end=" + range.endLine + + " bracket=" + range.bracket + "; using removedLines for shifts"); + } + // Compute the original last visible line that moved up by removedLines. + // After collapse: the "]" (bracket case) or nothing extra stays at headerLine+1. + int breakLine = range.bracket ? range.headerLine : range.endLine; + for (int i = 0; i < folded.size(); i++) { + FoldRegion fr = folded.get(i); + if (fr.headerLine > breakLine) { + folded.set(i, new FoldRegion(fr.headerLine - removedLines, fr.hiddenText, fr.originalHeaderLine)); + } + } + folded.add(new FoldRegion(range.headerLine, hidden)); } finally { txt.setRedraw(true); if (parser != null) { @@ -598,28 +668,6 @@ private void collapseInternal(FoldableRange range, boolean pushUndo) { inFoldOp = false; } - int removedLines = linesBefore - txt.getLineCount(); - // Sanity: the number of newlines captured must match what the buffer lost, - // or every shift derived from removedLines below is off. This has caught - // trailing-newline edge cases in the past where sliceEnd=charCount but the - // last line had no terminator; log loudly if it ever fires again. - int hiddenNL = countNewlines(hidden); - if (hiddenNL != removedLines) { - System.err.println("FoldingManager.collapse: removedLines/hiddenNL mismatch — removed=" + removedLines - + " hiddenNL=" + hiddenNL + " header=" + range.headerLine + " end=" + range.endLine - + " bracket=" + range.bracket + "; using removedLines for shifts"); - } - // Compute the original last visible line that moved up by removedLines. - // After collapse: the "]" (bracket case) or nothing extra stays at headerLine+1. - int breakLine = range.bracket ? range.headerLine : range.endLine; - for (int i = 0; i < folded.size(); i++) { - FoldRegion fr = folded.get(i); - if (fr.headerLine > breakLine) { - folded.set(i, new FoldRegion(fr.headerLine - removedLines, fr.hiddenText, fr.originalHeaderLine)); - } - } - folded.add(new FoldRegion(range.headerLine, hidden)); - if (!batchMode) recomputeRanges(); if (pushUndo) editor.pushFoldUndo(EditorComposite.FOLD_OP_COLLAPSE, range.headerLine); } @@ -638,6 +686,20 @@ private void expandInternal(FoldRegion region, boolean pushUndo) { if (parser != null) parser.setReparse(false); txt.setRedraw(false); txt.replaceTextRange(insertOffset, 0, region.hiddenText); + // Remove the expanded region from `folded` BEFORE the parser + // reparses. parser.reparseAll() reads canonical source via + // getUnfoldedText(), which re-inserts every still-folded entry. + // Leaving this region in `folded` after we've already written + // its body into the buffer would duplicate the hidden text in + // the canonical snapshot and corrupt every downstream offset. + folded.remove(region); + int addedLines = countNewlines(region.hiddenText); + for (int i = 0; i < folded.size(); i++) { + FoldRegion fr = folded.get(i); + if (fr.headerLine > region.headerLine) { + folded.set(i, new FoldRegion(fr.headerLine + addedLines, fr.hiddenText, fr.originalHeaderLine)); + } + } } finally { txt.setRedraw(true); if (parser != null) { @@ -647,15 +709,6 @@ private void expandInternal(FoldRegion region, boolean pushUndo) { inFoldOp = false; } - folded.remove(region); - int addedLines = countNewlines(region.hiddenText); - for (int i = 0; i < folded.size(); i++) { - FoldRegion fr = folded.get(i); - if (fr.headerLine > region.headerLine) { - folded.set(i, new FoldRegion(fr.headerLine + addedLines, fr.hiddenText, fr.originalHeaderLine)); - } - } - if (!batchMode) recomputeRanges(); if (pushUndo) editor.pushFoldUndo(EditorComposite.FOLD_OP_EXPAND, origHeaderLine); } diff --git a/com/repdev/parser/HiddenTextProvider.java b/com/repdev/parser/HiddenTextProvider.java index 5b2a043..2fb82f0 100644 --- a/com/repdev/parser/HiddenTextProvider.java +++ b/com/repdev/parser/HiddenTextProvider.java @@ -63,4 +63,14 @@ public interface HiddenTextProvider { * offset lies inside a currently-hidden region. */ int modelToView(int modelOffset); + + /** + * Notification that the parser has finished updating its token list. + * Folding implementations that derive foldable ranges from + * {@code parser.getLtokens()} should rebuild that cache here — driving + * the rebuild from this callback rather than a {@code modifyText} + * listener avoids the ordering trap where the listener fires before + * the parser has processed the same edit. + */ + void onTokensUpdated(); } diff --git a/com/repdev/parser/RepgenParser.java b/com/repdev/parser/RepgenParser.java index b5ceb04..f86cad3 100644 --- a/com/repdev/parser/RepgenParser.java +++ b/com/repdev/parser/RepgenParser.java @@ -1190,6 +1190,14 @@ public void textModified(int start, int length, String replacedText){ parseIncludes(); initialIncludeParseNeeded = false; } + + // Notify the folding manager (or any other registered + // projection) that ltokens reflects the latest edit. Driving + // fold-range recomputation from this callback rather than a + // modifyText listener avoids the registration-order trap + // where the listener would otherwise fire ahead of this + // parse pass and rebuild ranges against stale tokens. + if (hiddenTextProvider != null) hiddenTextProvider.onTokensUpdated(); } catch (Exception e) { System.err.println("Syntax Highlighter error!"); e.printStackTrace(); @@ -1222,6 +1230,7 @@ public void reparseAll() { String canonical = canonicalSourceText(); parse(file.getName(), canonical, 0, canonical.length() - 1, 0, null, ltokens, lasttokens, removedtokens, lvars, txt); rebuildVars(file.getName(), canonical, ltokens); + if (hiddenTextProvider != null) hiddenTextProvider.onTokensUpdated(); System.out.println("Reparsed"); } catch (Exception e) { System.err.println("Syntax Highlighter error!");