summaryrefslogtreecommitdiff
path: root/base/src/main/java/bjc/utils/gui/JsonEditorKit.java
diff options
context:
space:
mode:
Diffstat (limited to 'base/src/main/java/bjc/utils/gui/JsonEditorKit.java')
-rw-r--r--base/src/main/java/bjc/utils/gui/JsonEditorKit.java899
1 files changed, 899 insertions, 0 deletions
diff --git a/base/src/main/java/bjc/utils/gui/JsonEditorKit.java b/base/src/main/java/bjc/utils/gui/JsonEditorKit.java
new file mode 100644
index 0000000..46519cf
--- /dev/null
+++ b/base/src/main/java/bjc/utils/gui/JsonEditorKit.java
@@ -0,0 +1,899 @@
+package bjc.utils.gui;
+import javax.swing.*;
+import javax.swing.event.ChangeListener;
+import javax.swing.text.*;
+import java.awt.*;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.io.*;
+import java.util.Arrays;
+
+/**
+ * JSON syntax highlighting for JTextPane via StyledEditorKit/StyledDocument.
+ *
+ * Includes:
+ * 1) Highlights only the visible region (plus margin) for performance.
+ * 2) Rainbow brace/bracket coloring: each matching pair shares a color by nesting depth.
+ * 3) Caret pair emphasis: when caret is on/after a brace, emphasize it + its matching pair.
+ * 4) Underlines mismatched/unpaired braces/brackets.
+ *
+ * Usage:
+ * JTextPane pane = new JTextPane();
+ * pane.setEditorKit(new JsonEditorKit());
+ * JsonEditorKit.JsonHighlightSupport.install(pane);
+ * ((JsonEditorKit.JsonDocument)pane.getDocument()).setJson(jsonString);
+ */
+public class JsonEditorKit extends StyledEditorKit {
+ private static final long serialVersionUID = 7378968732241012969L;
+
+ @Override
+ public String getContentType() {
+ return "application/json";
+ }
+
+ @Override
+ public Document createDefaultDocument() {
+ return new JsonDocument();
+ }
+
+ /**
+ * Set the JSON contents of this document and a text pane
+ *
+ * @param pane The text pane
+ * @param json The JSON document
+ */
+ public void setJson(JTextPane pane, String json) {
+ if (!(pane.getDocument() instanceof JsonDocument)) {
+ pane.setDocument(createDefaultDocument());
+ }
+ ((JsonDocument) pane.getDocument()).setJson(json);
+ }
+
+ @Override
+ public void read(Reader in, Document doc, int pos) throws IOException, BadLocationException {
+ String json = readAll(in);
+ if (doc instanceof JsonDocument) {
+ ((JsonDocument) doc).insertJsonAt(pos, json);
+ } else if (doc instanceof StyledDocument) {
+ StyledDocument sd = (StyledDocument) doc;
+ sd.insertString(pos, json, null);
+ JsonHighlighter.highlight(sd, 0, sd.getLength(), JsonStyles.defaults(), null, -1);
+ } else {
+ super.read(new StringReader(json), doc, pos);
+ }
+ }
+
+ @Override
+ public void write(Writer out, Document doc, int pos, int len) throws IOException, BadLocationException {
+ out.write(doc.getText(pos, len));
+ }
+
+ private static String readAll(Reader r) throws IOException {
+ StringBuilder sb = new StringBuilder(4096);
+ char[] buf = new char[4096];
+ int n;
+ while ((n = r.read(buf)) >= 0)
+ sb.append(buf, 0, n);
+ return sb.toString();
+ }
+
+ // -------------------------------------------------------------------------
+ // Install helper: visible-region updates + caret updates
+ // -------------------------------------------------------------------------
+
+ /**
+ * Helper for doing JSON highlighting
+ */
+ public static final class JsonHighlightSupport {
+ private static final String INSTALLED_KEY = "json.highlighter.installed";
+ private static final int DEFAULT_MARGIN_CHARS = 2048;
+
+ private JsonHighlightSupport() {
+ }
+
+ /**
+ * Install the JSON highlighter into a pane
+ *
+ * @param pane The pane to install the highlighter into
+ */
+ public static void install(JTextPane pane) {
+ if (pane == null)
+ throw new IllegalArgumentException("pane == null");
+ if (Boolean.TRUE.equals(pane.getClientProperty(INSTALLED_KEY)))
+ return;
+ pane.putClientProperty(INSTALLED_KEY, Boolean.TRUE);
+
+ if (!(pane.getDocument() instanceof JsonDocument)) {
+ pane.setEditorKit(new JsonEditorKit());
+ }
+
+ final JsonDocument doc = (JsonDocument) pane.getDocument();
+ doc.attachView(pane);
+ doc.setVisibleHighlightMargin(DEFAULT_MARGIN_CHARS);
+
+ // Re-highlight on resize
+ pane.addComponentListener(new ComponentAdapter() {
+ @Override
+ public void componentResized(ComponentEvent e) {
+ doc.queueHighlight();
+ }
+ });
+
+ // Re-highlight on caret moves (pair emphasis)
+ pane.addCaretListener(e -> doc.queueHighlight());
+
+ // Re-highlight on viewport scrolling
+ final Runnable hookViewport = () -> {
+ JViewport vp = (JViewport) SwingUtilities.getAncestorOfClass(JViewport.class, pane);
+ if (vp == null)
+ return;
+
+ final String VP_LISTENER_KEY = "json.highlighter.viewport.listener";
+ if (vp.getClientProperty(VP_LISTENER_KEY) != null)
+ return;
+
+ ChangeListener cl = e -> doc.queueHighlight();
+ vp.addChangeListener(cl);
+ vp.putClientProperty(VP_LISTENER_KEY, cl);
+ };
+
+ pane.addHierarchyListener(e -> hookViewport.run());
+ hookViewport.run();
+
+ doc.queueHighlight();
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Document
+ // -------------------------------------------------------------------------
+
+ public static class JsonDocument extends DefaultStyledDocument {
+ private final JsonStyles styles = JsonStyles.defaults();
+
+ private transient JTextComponent view;
+
+ private boolean highlightQueued = false;
+ private int visibleMarginChars = 2048;
+
+ // Recompute rainbow pairing only when content changes
+ private boolean structureDirty = true;
+ private JsonHighlighter.RainbowData rainbowData = null;
+
+ public JsonDocument() {
+ super();
+
+ addDocumentListener(new javax.swing.event.DocumentListener() {
+ @Override
+ public void insertUpdate(javax.swing.event.DocumentEvent e) {
+ onContentChanged();
+ }
+
+ @Override
+ public void removeUpdate(javax.swing.event.DocumentEvent e) {
+ onContentChanged();
+ }
+
+ @Override
+ public void changedUpdate(javax.swing.event.DocumentEvent e) {
+ /* ignore attribute changes */ }
+ });
+ }
+
+ public void attachView(JTextComponent view) {
+ this.view = view;
+ }
+
+ public void setVisibleHighlightMargin(int chars) {
+ this.visibleMarginChars = Math.max(0, chars);
+ }
+
+ public void setJson(String json) {
+ try {
+ remove(0, getLength());
+ insertString(0, json, null);
+ } catch (BadLocationException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public void insertJsonAt(int pos, String json) throws BadLocationException {
+ insertString(pos, json, null);
+ }
+
+ private void onContentChanged() {
+ structureDirty = true;
+ queueHighlight();
+ }
+
+ void queueHighlight() {
+ if (highlightQueued)
+ return;
+ highlightQueued = true;
+
+ SwingUtilities.invokeLater(() -> {
+ highlightQueued = false;
+ try {
+ doHighlight();
+ } catch (BadLocationException ignored) {
+ }
+ });
+ }
+
+ private void doHighlight() throws BadLocationException {
+ int docLen = getLength();
+ if (docLen <= 0)
+ return;
+
+ // Recompute rainbow mapping when content changed
+ if (structureDirty) {
+ String fullText = getText(0, docLen);
+ rainbowData = JsonHighlighter.computeRainbow(fullText, styles.rainbowOverlays.length);
+ structureDirty = false;
+ }
+
+ int start = 0;
+ int end = docLen;
+ int caretPos = -1;
+
+ if (view != null && view.isShowing()) {
+ caretPos = view.getCaretPosition();
+ int[] range = computeVisibleRange(view, docLen, visibleMarginChars);
+ start = range[0];
+ end = range[1];
+ }
+
+ int len = Math.max(0, end - start);
+ if (len <= 0)
+ return;
+
+ // Determine whether caret is on/after a brace/bracket
+ int activeBraceOffset = -1;
+ if (caretPos >= 0 && rainbowData != null && rainbowData.offsets.length > 0) {
+ activeBraceOffset = findBraceAtCaret(this, caretPos, rainbowData);
+ }
+
+ JsonHighlighter.highlight(this, start, len, styles, rainbowData, activeBraceOffset);
+ }
+
+ private static int findBraceAtCaret(StyledDocument doc, int caretPos, JsonHighlighter.RainbowData rd) {
+ try {
+ int docLen = doc.getLength();
+
+ // Prefer caret-1 (common editor behavior: caret after brace highlights it)
+ int[] candidates = new int[] { caretPos - 1, caretPos };
+ for (int off : candidates) {
+ if (off < 0 || off >= docLen)
+ continue;
+
+ char ch = doc.getText(off, 1).charAt(0);
+ if (!(ch == '{' || ch == '}' || ch == '[' || ch == ']'))
+ continue;
+
+ // Only accept if this brace is actually part of the parsed structure
+ // (i.e., not in string/comment), which is represented by rd.offsets.
+ int idx = JsonHighlighter.lowerBound(rd.offsets, off);
+ if (idx < rd.offsets.length && rd.offsets[idx] == off)
+ return off;
+ }
+ } catch (BadLocationException ignored) {
+ }
+
+ return -1;
+ }
+
+ private static int[] computeVisibleRange(JTextComponent comp, int docLen, int marginChars) {
+ Rectangle r = comp.getVisibleRect();
+ Point p1 = new Point(r.x, r.y);
+ Point p2 = new Point(r.x + r.width, r.y + r.height);
+
+ int a = safeViewToModel(comp, p1);
+ int b = safeViewToModel(comp, p2);
+
+ int start = Math.max(0, Math.min(a, b));
+ int end = Math.min(docLen, Math.max(a, b));
+
+ start = Math.max(0, start - marginChars);
+ end = Math.min(docLen, end + marginChars);
+
+ if (end < start) {
+ int t = start;
+ start = end;
+ end = t;
+ }
+ return new int[] { start, end };
+ }
+
+ @SuppressWarnings("deprecation") // Java 8 compatible
+ private static int safeViewToModel(JTextComponent comp, Point p) {
+ try {
+ return Math.max(0, comp.viewToModel(p));
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Styles
+ // -------------------------------------------------------------------------
+
+ public static final class JsonStyles {
+ public final SimpleAttributeSet base = new SimpleAttributeSet();
+
+ public final SimpleAttributeSet key;
+ public final SimpleAttributeSet string;
+ public final SimpleAttributeSet number;
+ public final SimpleAttributeSet boolNull;
+ public final SimpleAttributeSet punctuation;
+ public final SimpleAttributeSet comment;
+
+ /** Rainbow overlays for braces/brackets (apply with replace=false). */
+ public final SimpleAttributeSet[] rainbowOverlays;
+
+ /** For mismatches/unpaired (apply with replace=false). */
+ public final SimpleAttributeSet mismatchOverlay;
+
+ /** Caret brace emphasis (stronger). */
+ public final SimpleAttributeSet caretBraceOverlay;
+
+ /** Matching brace emphasis (weaker). */
+ public final SimpleAttributeSet matchBraceOverlay;
+
+ private JsonStyles(SimpleAttributeSet key, SimpleAttributeSet string, SimpleAttributeSet number,
+ SimpleAttributeSet boolNull, SimpleAttributeSet punctuation, SimpleAttributeSet comment,
+ SimpleAttributeSet[] rainbowOverlays, SimpleAttributeSet mismatchOverlay,
+ SimpleAttributeSet caretBraceOverlay, SimpleAttributeSet matchBraceOverlay) {
+ this.key = key;
+ this.string = string;
+ this.number = number;
+ this.boolNull = boolNull;
+ this.punctuation = punctuation;
+ this.comment = comment;
+ this.rainbowOverlays = rainbowOverlays;
+ this.mismatchOverlay = mismatchOverlay;
+ this.caretBraceOverlay = caretBraceOverlay;
+ this.matchBraceOverlay = matchBraceOverlay;
+ }
+
+ public static JsonStyles defaults() {
+ // Base
+ SimpleAttributeSet base = new SimpleAttributeSet();
+ StyleConstants.setFontFamily(base, "Monospaced");
+ StyleConstants.setFontSize(base, 13);
+
+ SimpleAttributeSet key = cloneWith(base);
+ StyleConstants.setForeground(key, new Color(0, 92, 197));
+ StyleConstants.setBold(key, true);
+
+ SimpleAttributeSet string = cloneWith(base);
+ StyleConstants.setForeground(string, new Color(34, 139, 34));
+
+ SimpleAttributeSet number = cloneWith(base);
+ StyleConstants.setForeground(number, new Color(163, 73, 164));
+
+ SimpleAttributeSet boolNull = cloneWith(base);
+ StyleConstants.setForeground(boolNull, new Color(184, 92, 0));
+ StyleConstants.setBold(boolNull, true);
+
+ SimpleAttributeSet punctuation = cloneWith(base);
+ StyleConstants.setForeground(punctuation, new Color(120, 120, 120));
+
+ SimpleAttributeSet comment = cloneWith(base);
+ StyleConstants.setForeground(comment, new Color(140, 140, 140));
+ StyleConstants.setItalic(comment, true);
+
+ // Rainbow palette (light background friendly)
+ Color[] palette = new Color[] { new Color(216, 27, 96), // pink/red
+ new Color(255, 143, 0), // orange
+ new Color(251, 192, 45), // amber
+ new Color(67, 160, 71), // green
+ new Color(0, 172, 193), // cyan
+ new Color(30, 136, 229), // blue
+ new Color(94, 53, 177), // deep purple
+ new Color(142, 36, 170) // violet
+ };
+
+ SimpleAttributeSet[] rainbow = new SimpleAttributeSet[palette.length];
+ for (int i = 0; i < palette.length; i++) {
+ SimpleAttributeSet o = new SimpleAttributeSet();
+ StyleConstants.setForeground(o, palette[i]);
+ StyleConstants.setBold(o, true);
+ rainbow[i] = o;
+ }
+
+ // Mismatch: underline red + red foreground
+ SimpleAttributeSet mismatch = new SimpleAttributeSet();
+ StyleConstants.setUnderline(mismatch, true);
+ StyleConstants.setForeground(mismatch, new Color(200, 0, 0));
+ StyleConstants.setBold(mismatch, true);
+
+ // Caret brace emphasis (strong): background + underline
+ SimpleAttributeSet caretEm = new SimpleAttributeSet();
+ StyleConstants.setBackground(caretEm, new Color(255, 242, 179)); // slightly stronger yellow
+ StyleConstants.setUnderline(caretEm, true);
+ StyleConstants.setBold(caretEm, true);
+
+ // Matching brace emphasis (soft): background only
+ SimpleAttributeSet matchEm = new SimpleAttributeSet();
+ StyleConstants.setBackground(matchEm, new Color(255, 250, 210)); // softer yellow
+ StyleConstants.setBold(matchEm, true);
+
+ JsonStyles s = new JsonStyles(key, string, number, boolNull, punctuation, comment, rainbow, mismatch,
+ caretEm, matchEm);
+ s.base.addAttributes(base);
+ return s;
+ }
+
+ private static SimpleAttributeSet cloneWith(SimpleAttributeSet base) {
+ SimpleAttributeSet a = new SimpleAttributeSet();
+ a.addAttributes(base);
+ return a;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Highlighter / Lexer + Rainbow Pairing + Caret emphasis
+ // -------------------------------------------------------------------------
+
+ static final class JsonHighlighter {
+
+ /**
+ * Rainbow mapping: - offsets: positions of braces/brackets to color (sorted) -
+ * colorIndex: palette index for that brace/bracket - match: matching brace
+ * offset (or -1 if unpaired) - mismatches: offsets of unpaired/mismatched
+ * braces/brackets (sorted)
+ */
+ static final class RainbowData {
+ final int[] offsets;
+ final byte[] colorIndex;
+ final int[] match;
+ final int[] mismatches;
+
+ RainbowData(int[] offsets, byte[] colorIndex, int[] match, int[] mismatches) {
+ this.offsets = offsets;
+ this.colorIndex = colorIndex;
+ this.match = match;
+ this.mismatches = mismatches;
+ }
+ }
+
+ static void highlight(StyledDocument doc, int start, int length, JsonStyles styles, RainbowData rainbowData,
+ int activeBraceOffset) throws BadLocationException {
+
+ if (length <= 0)
+ return;
+
+ String text = doc.getText(start, length);
+
+ // Reset region
+ doc.setCharacterAttributes(start, length, styles.base, true);
+
+ int i = 0;
+ final int n = text.length();
+
+ while (i < n) {
+ char c = text.charAt(i);
+
+ if (isWs(c)) {
+ i++;
+ continue;
+ }
+
+ // punctuation
+ if (isPunct(c)) {
+ apply(doc, start + i, 1, styles.punctuation, true);
+ i++;
+ continue;
+ }
+
+ // comments (JSONC-ish): //... or /*...*/
+ if (c == '/' && i + 1 < n) {
+ char d = text.charAt(i + 1);
+ if (d == '/') {
+ int j = i + 2;
+ while (j < n && text.charAt(j) != '\n')
+ j++;
+ apply(doc, start + i, j - i, styles.comment, true);
+ i = j;
+ continue;
+ }
+ if (d == '*') {
+ int j = i + 2;
+ boolean closed = false;
+ while (j + 1 < n) {
+ if (text.charAt(j) == '*' && text.charAt(j + 1) == '/') {
+ j += 2;
+ closed = true;
+ break;
+ }
+ j++;
+ }
+ if (closed) {
+ apply(doc, start + i, j - i, styles.comment, true);
+ i = j;
+ continue;
+ } else {
+ apply(doc, start + i, n - i, styles.mismatchOverlay, false);
+ break;
+ }
+ }
+ }
+
+ // string
+ if (c == '"') {
+ int j = i + 1;
+ boolean escaped = false;
+ boolean closed = false;
+
+ while (j < n) {
+ char ch = text.charAt(j);
+ if (escaped) {
+ escaped = false;
+ } else if (ch == '\\') {
+ escaped = true;
+ } else if (ch == '"') {
+ j++;
+ closed = true;
+ break;
+ }
+ j++;
+ }
+
+ if (!closed) {
+ apply(doc, start + i, n - i, styles.mismatchOverlay, false);
+ break;
+ }
+
+ // Key detection: string followed by ':' after whitespace
+ int k = j;
+ while (k < n && isWs(text.charAt(k)))
+ k++;
+ boolean isKey = (k < n && text.charAt(k) == ':');
+
+ apply(doc, start + i, j - i, isKey ? styles.key : styles.string, true);
+ i = j;
+ continue;
+ }
+
+ // keywords: true false null
+ if (isAlpha(c)) {
+ int j = i + 1;
+ while (j < n && isAlpha(text.charAt(j)))
+ j++;
+ String word = text.substring(i, j);
+ if ("true".equals(word) || "false".equals(word) || "null".equals(word)) {
+ apply(doc, start + i, j - i, styles.boolNull, true);
+ }
+ i = j;
+ continue;
+ }
+
+ // number
+ if (c == '-' || isDigit(c)) {
+ int j = scanNumber(text, i);
+ if (j > i) {
+ apply(doc, start + i, j - i, styles.number, true);
+ i = j;
+ continue;
+ }
+ }
+
+ i++;
+ }
+
+ // Apply rainbow overlays for braces/brackets within region
+ if (rainbowData != null && rainbowData.offsets.length > 0) {
+ int regionStart = start;
+ int regionEnd = start + length;
+
+ int idx = lowerBound(rainbowData.offsets, regionStart);
+ while (idx < rainbowData.offsets.length) {
+ int off = rainbowData.offsets[idx];
+ if (off >= regionEnd)
+ break;
+
+ int paletteIdx = (rainbowData.colorIndex[idx] & 0xFF) % styles.rainbowOverlays.length;
+ apply(doc, off, 1, styles.rainbowOverlays[paletteIdx], false);
+
+ idx++;
+ }
+ }
+
+ // Apply mismatch underline on top (will override rainbow to red)
+ if (rainbowData != null && rainbowData.mismatches.length > 0) {
+ int regionStart = start;
+ int regionEnd = start + length;
+
+ int idx = lowerBound(rainbowData.mismatches, regionStart);
+ while (idx < rainbowData.mismatches.length) {
+ int off = rainbowData.mismatches[idx];
+ if (off >= regionEnd)
+ break;
+ apply(doc, off, 1, styles.mismatchOverlay, false);
+ idx++;
+ }
+ }
+
+ // Caret pair emphasis (on top of rainbow; mismatch still wins visually if
+ // present)
+ if (rainbowData != null && activeBraceOffset >= 0) {
+ int idx = lowerBound(rainbowData.offsets, activeBraceOffset);
+ if (idx < rainbowData.offsets.length && rainbowData.offsets[idx] == activeBraceOffset) {
+ int matchOff = rainbowData.match[idx];
+ if (matchOff >= 0) {
+ int regionStart = start;
+ int regionEnd = start + length;
+
+ // Emphasize active brace strongly
+ if (activeBraceOffset >= regionStart && activeBraceOffset < regionEnd) {
+ apply(doc, activeBraceOffset, 1, styles.caretBraceOverlay, false);
+ }
+ // Emphasize matching brace softly
+ if (matchOff >= regionStart && matchOff < regionEnd) {
+ apply(doc, matchOff, 1, styles.matchBraceOverlay, false);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Compute rainbow coloring for matching {} and [] pairs using nesting depth.
+ * Skips braces/brackets in strings and (optional) JSONC comments.
+ *
+ * Openers get color by current depth; closers get their opener's color.
+ * Unpaired/mismatched braces are recorded in mismatches; openers keep their
+ * rainbow color even if unclosed.
+ */
+ static RainbowData computeRainbow(String text, int paletteSize) {
+ if (text == null || text.isEmpty())
+ return new RainbowData(new int[0], new byte[0], new int[0], new int[0]);
+ if (paletteSize <= 0)
+ paletteSize = 1;
+
+ final int n = text.length();
+
+ // mapping arrays (offsets sorted by scan)
+ int[] offsets = new int[Math.min(4096, n)];
+ byte[] colors = new byte[offsets.length];
+ int[] match = new int[offsets.length];
+ int count = 0;
+
+ // mismatches (cap)
+ int capMismatch = 512;
+ int[] mism = new int[capMismatch];
+ int mismCount = 0;
+
+ // stack: store opener type + opener entry index
+ char[] stCh = new char[Math.min(1024, n)];
+ int[] stEntryIdx = new int[stCh.length];
+ int[] stPos = new int[stCh.length];
+ int top = 0;
+
+ boolean inString = false;
+ boolean escaped = false;
+ boolean inLineComment = false;
+ boolean inBlockComment = false;
+
+ for (int i = 0; i < n; i++) {
+ char c = text.charAt(i);
+
+ if (inLineComment) {
+ if (c == '\n')
+ inLineComment = false;
+ continue;
+ }
+ if (inBlockComment) {
+ if (c == '*' && i + 1 < n && text.charAt(i + 1) == '/') {
+ inBlockComment = false;
+ i++;
+ }
+ continue;
+ }
+
+ if (inString) {
+ if (escaped) {
+ escaped = false;
+ } else if (c == '\\') {
+ escaped = true;
+ } else if (c == '"') {
+ inString = false;
+ }
+ continue;
+ } else if (c == '"') {
+ inString = true;
+ escaped = false;
+ continue;
+ }
+
+ // JSONC-ish comments
+ if (c == '/' && i + 1 < n) {
+ char d = text.charAt(i + 1);
+ if (d == '/') {
+ inLineComment = true;
+ i++;
+ continue;
+ }
+ if (d == '*') {
+ inBlockComment = true;
+ i++;
+ continue;
+ }
+ }
+
+ // openers
+ if (c == '{' || c == '[') {
+ byte ci = (byte) (top % paletteSize);
+
+ // ensure mapping arrays capacity
+ if (count >= offsets.length) {
+ int newLen = Math.min(n, offsets.length * 2);
+ offsets = Arrays.copyOf(offsets, newLen);
+ colors = Arrays.copyOf(colors, newLen);
+ match = Arrays.copyOf(match, newLen);
+ }
+
+ offsets[count] = i;
+ colors[count] = ci;
+ match[count] = -1; // fill later
+ int entryIdx = count;
+ count++;
+
+ // push onto stack
+ if (top >= stCh.length) {
+ int newLen = Math.min(n, stCh.length * 2);
+ stCh = Arrays.copyOf(stCh, newLen);
+ stEntryIdx = Arrays.copyOf(stEntryIdx, newLen);
+ stPos = Arrays.copyOf(stPos, newLen);
+ }
+
+ stCh[top] = c;
+ stEntryIdx[top] = entryIdx;
+ stPos[top] = i;
+ top++;
+ continue;
+ }
+
+ // closers
+ if (c == '}' || c == ']') {
+ if (top == 0) {
+ if (mismCount < capMismatch)
+ mism[mismCount++] = i;
+ continue;
+ }
+
+ char open = stCh[top - 1];
+ int openEntryIdx = stEntryIdx[top - 1];
+
+ boolean matchType = (open == '{' && c == '}') || (open == '[' && c == ']');
+ if (!matchType) {
+ if (mismCount < capMismatch)
+ mism[mismCount++] = i;
+ // recovery: pop one to reduce cascading errors
+ top--;
+ continue;
+ }
+
+ // matching pair
+ top--;
+
+ // ensure mapping arrays capacity for closer entry
+ if (count >= offsets.length) {
+ int newLen = Math.min(n, offsets.length * 2);
+ offsets = Arrays.copyOf(offsets, newLen);
+ colors = Arrays.copyOf(colors, newLen);
+ match = Arrays.copyOf(match, newLen);
+ }
+
+ byte ci = colors[openEntryIdx];
+
+ offsets[count] = i;
+ colors[count] = ci;
+ match[count] = offsets[openEntryIdx]; // closer -> opener
+ match[openEntryIdx] = i; // opener -> closer
+ count++;
+ continue;
+ }
+ }
+
+ // leftover openers are mismatches (their match stays -1)
+ while (top > 0 && mismCount < capMismatch) {
+ top--;
+ mism[mismCount++] = stPos[top];
+ }
+
+ int[] outOffsets = Arrays.copyOf(offsets, count);
+ byte[] outColors = Arrays.copyOf(colors, count);
+ int[] outMatch = Arrays.copyOf(match, count);
+
+ int[] outMism = Arrays.copyOf(mism, mismCount);
+ Arrays.sort(outMism);
+
+ return new RainbowData(outOffsets, outColors, outMatch, outMism);
+ }
+
+ // --- helpers ---
+
+ private static void apply(StyledDocument doc, int offset, int len, AttributeSet style, boolean replace) {
+ doc.setCharacterAttributes(offset, len, style, replace);
+ }
+
+ private static boolean isWs(char c) {
+ return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f';
+ }
+
+ private static boolean isDigit(char c) {
+ return c >= '0' && c <= '9';
+ }
+
+ private static boolean isAlpha(char c) {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+ }
+
+ private static boolean isPunct(char c) {
+ return c == '{' || c == '}' || c == '[' || c == ']' || c == ':' || c == ',';
+ }
+
+ static int lowerBound(int[] a, int key) {
+ int lo = 0, hi = a.length;
+ while (lo < hi) {
+ int mid = (lo + hi) >>> 1;
+ if (a[mid] < key)
+ lo = mid + 1;
+ else
+ hi = mid;
+ }
+ return lo;
+ }
+
+ /**
+ * JSON number: -? (0|[1-9]\d*) (\.\d+)? ([eE][+-]?\d+)? Returns end index
+ * (exclusive). If not a valid prefix, returns i.
+ */
+ private static int scanNumber(String s, int i) {
+ int n = s.length();
+ int j = i;
+
+ if (j < n && s.charAt(j) == '-')
+ j++;
+
+ int intStart = j;
+
+ if (j < n && s.charAt(j) == '0') {
+ j++;
+ } else {
+ if (j >= n || !isDigit(s.charAt(j)))
+ return i;
+ while (j < n && isDigit(s.charAt(j)))
+ j++;
+ }
+
+ // fraction
+ if (j < n && s.charAt(j) == '.') {
+ int dot = j;
+ j++;
+ if (j >= n || !isDigit(s.charAt(j)))
+ return dot;
+ while (j < n && isDigit(s.charAt(j)))
+ j++;
+ }
+
+ // exponent
+ if (j < n && (s.charAt(j) == 'e' || s.charAt(j) == 'E')) {
+ int epos = j;
+ j++;
+ if (j < n && (s.charAt(j) == '+' || s.charAt(j) == '-'))
+ j++;
+ if (j >= n || !isDigit(s.charAt(j)))
+ return epos;
+ while (j < n && isDigit(s.charAt(j)))
+ j++;
+ }
+
+ if (j == intStart)
+ return i;
+ return j;
+ }
+ }
+} \ No newline at end of file