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 2a5c7f0..0560097 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; @@ -151,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; @@ -166,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; @@ -186,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) { @@ -230,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()); + } } @@ -276,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)); } @@ -307,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(); @@ -500,13 +563,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 = (folding != null) ? folding.getUnfoldedLineCount() : txt.getLineCount(); + 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()); @@ -533,17 +607,34 @@ 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()); + // 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(){ public void widgetDisposed(DisposeEvent e) { + if( folding != null) + folding.dispose(); if( parser != null) parser.cleanupTokenCache(); } @@ -587,6 +678,32 @@ 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, 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); + boolean needsExpand = false; + for (int ln = startLine; ln <= endLine; ln++) { + if (folding.foldedAtLine(ln) != null) { needsExpand = true; break; } + } + 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")) { if(snippetMode){ @@ -638,42 +755,43 @@ 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; } + 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; + gc.drawString(num, nx, y, true); + } + gc.setForeground(oldFg); } }); @@ -681,6 +799,10 @@ public void paintControl (PaintEvent e){ public void modifyText(ExtendedModifyEvent event) { lineHighlight(); + // 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(); @@ -692,7 +814,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 +892,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,27 +974,38 @@ 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) ) { 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 @@ -887,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); @@ -901,6 +1041,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)){ @@ -1143,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); @@ -1273,7 +1424,33 @@ public void menuShown(MenuEvent e) { frmTxt.bottom = new FormAttachment(100); txt.setLayoutData(frmTxt); - if( parser != null && !file.isLocal()) + // 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(); undoMode = 1; @@ -1283,20 +1460,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()); @@ -1314,17 +1502,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; @@ -1349,9 +1537,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: + * + * + *

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; @@ -1369,18 +1687,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; @@ -1412,21 +1725,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; @@ -1447,15 +1754,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. @@ -1498,7 +1810,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); } /** @@ -1515,15 +1832,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(); + } } } @@ -1611,10 +1926,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; @@ -1630,6 +1950,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 /** @@ -1637,7 +2022,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(); @@ -1673,7 +2059,7 @@ public void saveFile( boolean errorCheck ){ } - if( parser != null && errorCheck && !file.isLocal()) + if( parser != null && errorCheck ) parser.errorCheck(); } @@ -1853,11 +2239,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; } @@ -1872,9 +2260,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()); } @@ -1982,9 +2371,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() { @@ -1996,7 +2388,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); } } @@ -2012,6 +2405,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()); @@ -2105,7 +2500,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); @@ -2117,7 +2518,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/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 new file mode 100644 index 0000000..41a1e17 --- /dev/null +++ b/com/repdev/FoldingManager.java @@ -0,0 +1,984 @@ +/** + * 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.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; +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.HiddenTextProvider; +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 implements HiddenTextProvider { + + /** 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; + /** + * 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; + } + } + + /** 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; + // 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; + this.txt = txt; + this.parser = parser; + this.markerColor = new Color(txt.getDisplay(), new RGB(90, 90, 90)); + install(); + } + + 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); + } + }); + + 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); + } + }); + } + + /** + * 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; + + // 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(); + 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); + } + } + } + + /** + * 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(); + } + + public int getExtraGutterWidth() { return FOLD_COLUMN_WIDTH; } + + public boolean isInFoldOp() { return inFoldOp; } + + 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 String getFullSourceText() { + return getUnfoldedText(); + } + + 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); + } + + 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 + * 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, fr.originalHeaderLine)); + } + } + } + + /** + * 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. + // 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(); + 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(); + 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 = modelToView(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)) { + // 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()) { + 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()); + if (hStart < 0 || hStart >= charCount) continue; + try { + int hl = txt.getLineAtOffset(hStart); + int el = txt.getLineAtOffset(eStartView); + boolean isBracket = "[".equals(head.getStr()); + if (el - hl >= 1) foldable.add(new FoldableRange(hl, el, eStartView, 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() { collapseAllInternal(true); } + + /** Undo-replay entry point — collapses without pushing a new undo marker. */ + 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() { + public int compare(FoldableRange a, FoldableRange b) { return b.headerLine - a.headerLine; } + }); + boolean prevBatch = batchMode; + batchMode = true; + HashSet prevSnapshot = preBatchHiddenTexts; + preBatchHiddenTexts = new HashSet(); + // 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++) { + 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() { 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); + + inFoldOp = true; + try { + 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) { + parser.setReparse(true); + parser.reparseAll(); + } + inFoldOp = false; + } + + 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); + } + + /** 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); + 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; } + }); + + // 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; + 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, ""); + + // 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) { + parser.setReparse(true); + if (!batchMode) parser.reparseAll(); + } + inFoldOp = false; + } + + if (!batchMode) recomputeRanges(); + if (pushUndo) editor.pushFoldUndo(EditorComposite.FOLD_OP_COLLAPSE, range.headerLine); + } + + 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() + : txt.getOffsetAtLine(region.headerLine + 1); + + inFoldOp = true; + try { + 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) { + parser.setReparse(true); + if (!batchMode) parser.reparseAll(); + } + inFoldOp = false; + } + + if (!batchMode) recomputeRanges(); + if (pushUndo) editor.pushFoldUndo(EditorComposite.FOLD_OP_EXPAND, origHeaderLine); + } + + /** + * 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(); + 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(visibleText); + 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; + } + } + // 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(); + } + + 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; + } + + // ------------------------------------------------------------------ + // 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++) { + 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(); + 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..a580132 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; /** @@ -3030,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(); } } @@ -3072,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/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 diff --git a/com/repdev/parser/HiddenTextProvider.java b/com/repdev/parser/HiddenTextProvider.java new file mode 100644 index 0000000..2fb82f0 --- /dev/null +++ b/com/repdev/parser/HiddenTextProvider.java @@ -0,0 +1,76 @@ +/** + * 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(); + + /** + * 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(); + + /** + * 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); + + /** + * 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 527dcf2..f86cad3 100644 --- a/com/repdev/parser/RepgenParser.java +++ b/com/repdev/parser/RepgenParser.java @@ -70,6 +70,19 @@ 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; + } + + 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) @@ -223,6 +236,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(); @@ -245,9 +274,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)); + } } @@ -264,13 +301,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)); + } } } } @@ -312,14 +346,26 @@ 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)); - } + // 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() && 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 @@ -376,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() { @@ -830,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; @@ -1014,17 +1073,64 @@ private synchronized void rebuildVars(String fileName, String data, ArrayListThis 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}. + */ + public String canonicalSourceText() { + if (hiddenTextProvider != null) { + String full = hiddenTextProvider.getFullSourceText(); + if (full != null) return full; + } + return txt.getText(); + } + + /** + * 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; + // 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() ) @@ -1078,12 +1184,20 @@ public void textModified(int start, int length, String replacedText){ if( rebuildVars ) - rebuildVars(file.getName(), txt.getText(), ltokens); + rebuildVars(file.getName(), canonical, ltokens); if( initialIncludeParseNeeded ){ 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(); @@ -1095,7 +1209,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(); } @@ -1111,8 +1227,10 @@ 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); - rebuildVars(file.getName(), txt.getText(), ltokens); + 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!"); @@ -1182,6 +1300,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; }