diff --git a/build.gradle b/build.gradle
index 7d2d52a..19571ad 100644
--- a/build.gradle
+++ b/build.gradle
@@ -7,7 +7,7 @@ plugins {
}
group 'xmonader'
-version '3.0'
+version '4.1'
java {
sourceCompatibility = JavaVersion.VERSION_11
diff --git a/src/main/java/com/sercapnp/actions/ShiftOrdinalsAction.java b/src/main/java/com/sercapnp/actions/ShiftOrdinalsAction.java
new file mode 100644
index 0000000..05fe151
--- /dev/null
+++ b/src/main/java/com/sercapnp/actions/ShiftOrdinalsAction.java
@@ -0,0 +1,412 @@
+package com.sercapnp.actions;
+
+import com.intellij.openapi.actionSystem.AnAction;
+import com.intellij.openapi.actionSystem.AnActionEvent;
+import com.intellij.openapi.actionSystem.CommonDataKeys;
+import com.intellij.openapi.command.WriteCommandAction;
+import com.intellij.openapi.editor.Document;
+import com.intellij.openapi.editor.Editor;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.ui.Messages;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Shifts ordinals down (increments) to create a gap for inserting a new field.
+ *
+ * Usage:
+ * 1. Place cursor on the line with @N (the field AFTER which you want to insert)
+ * 2. Invoke via Ctrl+Alt+A, S (or menu: Tools → Shift Ordinals Down)
+ * 3. All ordinals > N within the same struct scope are incremented by 1
+ * 4. @(N+1) is now free — type your new field with that ordinal
+ *
+ * Scope rules (per Cap'n Proto spec):
+ * - union {} and group {} share the parent struct's ordinal space → INCLUDED
+ * - Nested struct/enum/interface have their OWN ordinal space → SKIPPED
+ */
+public class ShiftOrdinalsAction extends AnAction {
+
+ // Matches @N where N is a decimal number (not @0x... hex IDs)
+ private static final Pattern ORDINAL_PATTERN = Pattern.compile("@(\\d+)");
+
+ // Matches the keyword before a { to determine block type
+ private static final Pattern BLOCK_KEYWORD_PATTERN = Pattern.compile(
+ "(struct|enum|interface|union|group)\\s+\\w*\\s*(?:\\([^)]*\\))?\\s*(?:@0x[a-fA-F0-9]+)?\\s*$"
+ );
+
+ @Override
+ public void actionPerformed(@NotNull AnActionEvent e) {
+ Editor editor = e.getData(CommonDataKeys.EDITOR);
+ Project project = e.getData(CommonDataKeys.PROJECT);
+ if (editor == null || project == null) return;
+
+ Document document = editor.getDocument();
+ String text = document.getText();
+ int caretOffset = editor.getCaretModel().getOffset();
+
+ // 1. Find the ordinal on the current line
+ int lineNumber = document.getLineNumber(caretOffset);
+ int lineStart = document.getLineStartOffset(lineNumber);
+ int lineEnd = document.getLineEndOffset(lineNumber);
+ String lineText = text.substring(lineStart, lineEnd);
+
+ Matcher lineMatcher = ORDINAL_PATTERN.matcher(lineText);
+ if (!lineMatcher.find()) {
+ Messages.showInfoMessage(project,
+ "No ordinal (@N) found on the current line.\n" +
+ "Place your cursor on the line with the ordinal after which you want to insert.",
+ "Shift Ordinals");
+ return;
+ }
+
+ int threshold = Integer.parseInt(lineMatcher.group(1));
+
+ // 2. Find the enclosing struct scope
+ int[] scope = findEnclosingStructScope(text, caretOffset);
+ if (scope == null) {
+ Messages.showInfoMessage(project,
+ "Could not find an enclosing struct scope.",
+ "Shift Ordinals");
+ return;
+ }
+
+ int scopeStart = scope[0]; // Position of '{'
+ int scopeEnd = scope[1]; // Position of '}'
+
+ // 3. Collect all ordinal positions within scope that need shifting
+ // Skip ordinals inside nested struct/enum/interface blocks
+ List hits = collectOrdinalsInScope(text, scopeStart, scopeEnd, threshold);
+
+ if (hits.isEmpty()) {
+ Messages.showInfoMessage(project,
+ "No ordinals found after @" + threshold + " to shift.",
+ "Shift Ordinals");
+ return;
+ }
+
+ // 4. Apply replacements from end to start (preserves earlier offsets)
+ Collections.sort(hits, (a, b) -> Integer.compare(b.offset, a.offset));
+
+ com.intellij.openapi.vfs.VirtualFile vFile =
+ com.intellij.openapi.fileEditor.FileDocumentManager.getInstance().getFile(document);
+
+ WriteCommandAction.runWriteCommandAction(project, "Shift Ordinals Down", null, () -> {
+ for (OrdinalHit hit : hits) {
+ String replacement = "@" + (hit.value + 1);
+ document.replaceString(hit.offset, hit.offset + hit.length, replacement);
+ }
+ }, vFile != null ?
+ com.intellij.psi.PsiManager.getInstance(project).findFile(vFile) :
+ null);
+
+ // Show result
+ com.intellij.openapi.editor.EditorModificationUtil.scrollToCaret(editor);
+ }
+
+ @Override
+ public void update(@NotNull AnActionEvent e) {
+ // Only enable in .capnp files
+ Editor editor = e.getData(CommonDataKeys.EDITOR);
+ boolean enabled = false;
+
+ if (editor != null) {
+ com.intellij.openapi.vfs.VirtualFile vFile =
+ com.intellij.openapi.fileEditor.FileDocumentManager.getInstance()
+ .getFile(editor.getDocument());
+ if (vFile != null) {
+ enabled = vFile.getName().endsWith(".capnp");
+ }
+ }
+
+ e.getPresentation().setEnabledAndVisible(enabled);
+ }
+
+ // ── Scope detection ─────────────────────────────────────────────────────────
+
+ /**
+ * Find the enclosing struct's { } boundaries.
+ *
+ * Walks backwards from caretOffset, tracking brace depth.
+ * When hitting a '{' at depth 0, checks if the preceding keyword is:
+ * - struct → found it, return [bracePos, matchingClose]
+ * - union/group → continue walking (these share parent struct's ordinals)
+ * - enum/interface → also valid top-level scope, return it
+ */
+ private int[] findEnclosingStructScope(String text, int caretOffset) {
+ int depth = 0;
+ boolean inString = false;
+ boolean inComment = false;
+
+ for (int i = caretOffset - 1; i >= 0; i--) {
+ char c = text.charAt(i);
+
+ // Reverse comment detection: if we hit \n, clear comment state
+ if (c == '\n') {
+ inComment = false;
+ continue;
+ }
+
+ if (inString) {
+ if (c == '"' && (i == 0 || text.charAt(i - 1) != '\\')) {
+ inString = false;
+ }
+ continue;
+ }
+
+ // Check if this position is inside a comment
+ // (walk forward to check if there's a # before us on this line)
+ if (c == '#') {
+ inComment = true;
+ continue;
+ }
+ if (inComment) continue;
+
+ if (c == '"') {
+ inString = true;
+ continue;
+ }
+
+ if (c == '}') {
+ depth++;
+ } else if (c == '{') {
+ if (depth == 0) {
+ // This '{' is our potential scope opener
+ String blockType = getBlockType(text, i);
+
+ if ("struct".equals(blockType) || "enum".equals(blockType)
+ || "interface".equals(blockType)) {
+ // Found our scope — find the matching '}'
+ int closePos = findMatchingClose(text, i);
+ if (closePos >= 0) {
+ return new int[]{i, closePos};
+ }
+ return null;
+ }
+
+ // union/group → keep going, ordinals belong to the parent struct
+ // (don't change depth — we're looking for the PARENT's '{')
+ continue;
+ }
+ depth--;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Determine the keyword (struct/enum/interface/union/group) before a '{'.
+ */
+ private String getBlockType(String text, int bracePos) {
+ String before = text.substring(Math.max(0, bracePos - 200), bracePos).trim();
+ Matcher m = BLOCK_KEYWORD_PATTERN.matcher(before);
+ if (m.find()) {
+ return m.group(1);
+ }
+
+ // Fallback: check for anonymous union (just "union {")
+ if (before.endsWith("union")) return "union";
+ if (before.endsWith("group")) return "group";
+
+ // Check for simple patterns without identifiers
+ String trimmed = before.replaceAll("\\s+", " ").trim();
+ if (trimmed.endsWith("union")) return "union";
+
+ return null;
+ }
+
+ /**
+ * Find the matching '}' for a '{' at the given position.
+ */
+ private int findMatchingClose(String text, int openPos) {
+ int depth = 0;
+ boolean inString = false;
+ boolean inComment = false;
+
+ for (int i = openPos; i < text.length(); i++) {
+ char c = text.charAt(i);
+
+ if (inComment) {
+ if (c == '\n') inComment = false;
+ continue;
+ }
+ if (inString) {
+ if (c == '\\') { i++; continue; }
+ if (c == '"') inString = false;
+ continue;
+ }
+ if (c == '#') { inComment = true; continue; }
+ if (c == '"') { inString = true; continue; }
+
+ if (c == '{') depth++;
+ else if (c == '}') {
+ depth--;
+ if (depth == 0) return i;
+ }
+ }
+ return -1;
+ }
+
+ // ── Ordinal collection ──────────────────────────────────────────────────────
+
+ /**
+ * Collect all @N ordinals within [scopeStart, scopeEnd] where N > threshold.
+ *
+ * Skips ordinals inside nested struct/enum/interface blocks (they have
+ * independent ordinal spaces). Includes ordinals inside union/group
+ * (they share the parent struct's ordinal space).
+ */
+ private List collectOrdinalsInScope(String text, int scopeStart,
+ int scopeEnd, int threshold) {
+ List hits = new ArrayList<>();
+
+ // Track which ranges to skip (nested struct/enum/interface bodies)
+ List skipRanges = findNestedIndependentBlocks(text, scopeStart, scopeEnd);
+
+ // Scan for all @N in the scope
+ Matcher m = ORDINAL_PATTERN.matcher(text);
+ int searchStart = scopeStart + 1; // Skip the opening '{'
+
+ while (m.find(searchStart)) {
+ if (m.start() >= scopeEnd) break;
+
+ int ordinalValue = Integer.parseInt(m.group(1));
+ int matchStart = m.start();
+
+ // Skip if inside a nested struct/enum/interface
+ if (isInSkipRange(matchStart, skipRanges)) {
+ searchStart = m.end();
+ continue;
+ }
+
+ // Check this isn't a hex ID (@0x...)
+ // ORDINAL_PATTERN only matches @digits, but @0 followed by x would match @0
+ // The regex already handles this — @0x would match @0 then 'x' is after
+ // Double-check: if the character after the match is 'x' or 'X', skip
+ int afterMatch = m.end();
+ if (afterMatch < text.length() &&
+ (text.charAt(afterMatch) == 'x' || text.charAt(afterMatch) == 'X')) {
+ searchStart = m.end();
+ continue;
+ }
+
+ if (ordinalValue > threshold) {
+ hits.add(new OrdinalHit(matchStart, m.end() - matchStart, ordinalValue));
+ }
+
+ searchStart = m.end();
+ }
+
+ return hits;
+ }
+
+ /**
+ * Find all nested struct/enum/interface block ranges that have independent
+ * ordinal spaces. These ranges will be skipped during ordinal shifting.
+ *
+ * union {} and group {} are NOT included — they share the parent's ordinals.
+ */
+ private List findNestedIndependentBlocks(String text, int scopeStart, int scopeEnd) {
+ List ranges = new ArrayList<>();
+
+ // We need to find nested struct/enum/interface definitions
+ // Pattern: (struct|enum|interface) Name ... {
+ Pattern nestedDef = Pattern.compile(
+ "\\b(struct|enum|interface)\\s+[A-Z][A-Za-z0-9]*\\s*(?:\\([^)]*\\))?\\s*(?:@0x[a-fA-F0-9]+)?\\s*\\{"
+ );
+
+ int depth = 0;
+ boolean inString = false;
+ boolean inComment = false;
+
+ // First pass: find the depth-1 struct/enum/interface blocks
+ // (depth 0 = the scope itself, depth 1 = direct children)
+ Matcher m = nestedDef.matcher(text);
+ int searchFrom = scopeStart + 1;
+
+ while (m.find(searchFrom)) {
+ int defStart = m.start();
+ if (defStart >= scopeEnd) break;
+
+ // Verify this isn't inside a string or comment
+ if (isInStringOrComment(text, scopeStart, defStart)) {
+ searchFrom = m.end();
+ continue;
+ }
+
+ // Find the '{' in this match
+ int bracePos = text.indexOf('{', m.start());
+ if (bracePos < 0 || bracePos >= scopeEnd) {
+ searchFrom = m.end();
+ continue;
+ }
+
+ // Find its matching '}'
+ int closePos = findMatchingClose(text, bracePos);
+ if (closePos > 0 && closePos <= scopeEnd) {
+ ranges.add(new int[]{bracePos, closePos});
+ searchFrom = closePos + 1;
+ } else {
+ searchFrom = m.end();
+ }
+ }
+
+ return ranges;
+ }
+
+ /**
+ * Check if a position falls within any of the skip ranges.
+ */
+ private boolean isInSkipRange(int pos, List ranges) {
+ for (int[] range : ranges) {
+ if (pos > range[0] && pos < range[1]) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Simple check if a position is inside a string or comment.
+ */
+ private boolean isInStringOrComment(String text, int from, int pos) {
+ boolean inString = false;
+ boolean inComment = false;
+
+ for (int i = from; i < pos && i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (inComment) {
+ if (c == '\n') inComment = false;
+ continue;
+ }
+ if (inString) {
+ if (c == '\\') { i++; continue; }
+ if (c == '"') inString = false;
+ continue;
+ }
+ if (c == '#') inComment = true;
+ else if (c == '"') inString = true;
+ }
+
+ return inString || inComment;
+ }
+
+ // ── Data class ──────────────────────────────────────────────────────────────
+
+ private static class OrdinalHit {
+ final int offset; // Position of '@' in the document
+ final int length; // Length of "@N" text
+ final int value; // The ordinal number N
+
+ OrdinalHit(int offset, int length, int value) {
+ this.offset = offset;
+ this.length = length;
+ this.value = value;
+ }
+ }
+}
diff --git a/src/main/java/com/sercapnp/actions/UnshiftOrdinalsAction.java b/src/main/java/com/sercapnp/actions/UnshiftOrdinalsAction.java
new file mode 100644
index 0000000..e5782ea
--- /dev/null
+++ b/src/main/java/com/sercapnp/actions/UnshiftOrdinalsAction.java
@@ -0,0 +1,304 @@
+package com.sercapnp.actions;
+
+import com.intellij.openapi.actionSystem.AnAction;
+import com.intellij.openapi.actionSystem.AnActionEvent;
+import com.intellij.openapi.actionSystem.CommonDataKeys;
+import com.intellij.openapi.command.WriteCommandAction;
+import com.intellij.openapi.editor.Document;
+import com.intellij.openapi.editor.Editor;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.ui.Messages;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Shifts ordinals up (decrements) to close a gap left by a removed field.
+ *
+ * Usage:
+ * 1. Remove the field (e.g., delete the line with @5)
+ * 2. Place cursor on the first line AFTER the gap (the line with @6)
+ * 3. Invoke via Ctrl+Alt+A, U (or menu: Tools → Shift Ordinals Up)
+ * 4. All ordinals >= 6 within the same struct scope are decremented by 1
+ * 5. Result: @6→@5, @7→@6, ..., @45→@44
+ *
+ * Scope rules (per Cap'n Proto spec):
+ * - union {} and group {} share the parent struct's ordinal space → INCLUDED
+ * - Nested struct/enum/interface have their OWN ordinal space → SKIPPED
+ */
+public class UnshiftOrdinalsAction extends AnAction {
+
+ private static final Pattern ORDINAL_PATTERN = Pattern.compile("@(\\d+)");
+
+ private static final Pattern BLOCK_KEYWORD_PATTERN = Pattern.compile(
+ "(struct|enum|interface|union|group)\\s+\\w*\\s*(?:\\([^)]*\\))?\\s*(?:@0x[a-fA-F0-9]+)?\\s*$"
+ );
+
+ @Override
+ public void actionPerformed(@NotNull AnActionEvent e) {
+ Editor editor = e.getData(CommonDataKeys.EDITOR);
+ Project project = e.getData(CommonDataKeys.PROJECT);
+ if (editor == null || project == null) return;
+
+ Document document = editor.getDocument();
+ String text = document.getText();
+ int caretOffset = editor.getCaretModel().getOffset();
+
+ // 1. Find the ordinal on the current line
+ int lineNumber = document.getLineNumber(caretOffset);
+ int lineStart = document.getLineStartOffset(lineNumber);
+ int lineEnd = document.getLineEndOffset(lineNumber);
+ String lineText = text.substring(lineStart, lineEnd);
+
+ Matcher lineMatcher = ORDINAL_PATTERN.matcher(lineText);
+ if (!lineMatcher.find()) {
+ Messages.showInfoMessage(project,
+ "No ordinal (@N) found on the current line.\n" +
+ "Place your cursor on the first line after the removed field.",
+ "Shift Ordinals Up");
+ return;
+ }
+
+ int threshold = Integer.parseInt(lineMatcher.group(1));
+
+ if (threshold == 0) {
+ Messages.showInfoMessage(project,
+ "Cannot shift @0 — it is already the first ordinal.",
+ "Shift Ordinals Up");
+ return;
+ }
+
+ // 2. Find the enclosing struct scope
+ int[] scope = findEnclosingStructScope(text, caretOffset);
+ if (scope == null) {
+ Messages.showInfoMessage(project,
+ "Could not find an enclosing struct scope.",
+ "Shift Ordinals Up");
+ return;
+ }
+
+ int scopeStart = scope[0];
+ int scopeEnd = scope[1];
+
+ // 3. Collect ordinals >= threshold that need shifting
+ List hits = collectOrdinalsInScope(text, scopeStart, scopeEnd, threshold);
+
+ if (hits.isEmpty()) {
+ Messages.showInfoMessage(project,
+ "No ordinals found at or after @" + threshold + " to shift.",
+ "Shift Ordinals Up");
+ return;
+ }
+
+ // 4. Apply replacements from end to start
+ Collections.sort(hits, (a, b) -> Integer.compare(b.offset, a.offset));
+
+ com.intellij.openapi.vfs.VirtualFile vFile =
+ com.intellij.openapi.fileEditor.FileDocumentManager.getInstance().getFile(document);
+
+ WriteCommandAction.runWriteCommandAction(project, "Shift Ordinals Up", null, () -> {
+ for (OrdinalHit hit : hits) {
+ String replacement = "@" + (hit.value - 1);
+ document.replaceString(hit.offset, hit.offset + hit.length, replacement);
+ }
+ }, vFile != null ?
+ com.intellij.psi.PsiManager.getInstance(project).findFile(vFile) :
+ null);
+
+ com.intellij.openapi.editor.EditorModificationUtil.scrollToCaret(editor);
+ }
+
+ @Override
+ public void update(@NotNull AnActionEvent e) {
+ Editor editor = e.getData(CommonDataKeys.EDITOR);
+ boolean enabled = false;
+
+ if (editor != null) {
+ com.intellij.openapi.vfs.VirtualFile vFile =
+ com.intellij.openapi.fileEditor.FileDocumentManager.getInstance()
+ .getFile(editor.getDocument());
+ if (vFile != null) {
+ enabled = vFile.getName().endsWith(".capnp");
+ }
+ }
+
+ e.getPresentation().setEnabledAndVisible(enabled);
+ }
+
+ // ── Scope detection (same logic as ShiftOrdinalsAction) ──────────────────────
+
+ private int[] findEnclosingStructScope(String text, int caretOffset) {
+ int depth = 0;
+ boolean inString = false;
+ boolean inComment = false;
+
+ for (int i = caretOffset - 1; i >= 0; i--) {
+ char c = text.charAt(i);
+
+ if (c == '\n') { inComment = false; continue; }
+ if (inString) {
+ if (c == '"' && (i == 0 || text.charAt(i - 1) != '\\')) inString = false;
+ continue;
+ }
+ if (c == '#') { inComment = true; continue; }
+ if (inComment) continue;
+ if (c == '"') { inString = true; continue; }
+
+ if (c == '}') {
+ depth++;
+ } else if (c == '{') {
+ if (depth == 0) {
+ String blockType = getBlockType(text, i);
+ if ("struct".equals(blockType) || "enum".equals(blockType)
+ || "interface".equals(blockType)) {
+ int closePos = findMatchingClose(text, i);
+ if (closePos >= 0) return new int[]{i, closePos};
+ return null;
+ }
+ continue; // union/group — keep going up
+ }
+ depth--;
+ }
+ }
+ return null;
+ }
+
+ private String getBlockType(String text, int bracePos) {
+ String before = text.substring(Math.max(0, bracePos - 200), bracePos).trim();
+ Matcher m = BLOCK_KEYWORD_PATTERN.matcher(before);
+ if (m.find()) return m.group(1);
+ if (before.endsWith("union")) return "union";
+ if (before.endsWith("group")) return "group";
+ return null;
+ }
+
+ private int findMatchingClose(String text, int openPos) {
+ int depth = 0;
+ boolean inString = false;
+ boolean inComment = false;
+
+ for (int i = openPos; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (inComment) { if (c == '\n') inComment = false; continue; }
+ if (inString) {
+ if (c == '\\') { i++; continue; }
+ if (c == '"') inString = false;
+ continue;
+ }
+ if (c == '#') { inComment = true; continue; }
+ if (c == '"') { inString = true; continue; }
+ if (c == '{') depth++;
+ else if (c == '}') { depth--; if (depth == 0) return i; }
+ }
+ return -1;
+ }
+
+ // ── Ordinal collection ──────────────────────────────────────────────────────
+
+ /**
+ * Collect all @N ordinals within scope where N >= threshold.
+ * Skips nested struct/enum/interface blocks.
+ */
+ private List collectOrdinalsInScope(String text, int scopeStart,
+ int scopeEnd, int threshold) {
+ List hits = new ArrayList<>();
+ List skipRanges = findNestedIndependentBlocks(text, scopeStart, scopeEnd);
+
+ Matcher m = ORDINAL_PATTERN.matcher(text);
+ int searchStart = scopeStart + 1;
+
+ while (m.find(searchStart)) {
+ if (m.start() >= scopeEnd) break;
+
+ int ordinalValue = Integer.parseInt(m.group(1));
+ int matchStart = m.start();
+
+ if (isInSkipRange(matchStart, skipRanges)) {
+ searchStart = m.end();
+ continue;
+ }
+
+ // Skip hex IDs
+ int afterMatch = m.end();
+ if (afterMatch < text.length() &&
+ (text.charAt(afterMatch) == 'x' || text.charAt(afterMatch) == 'X')) {
+ searchStart = m.end();
+ continue;
+ }
+
+ if (ordinalValue >= threshold) {
+ hits.add(new OrdinalHit(matchStart, m.end() - matchStart, ordinalValue));
+ }
+
+ searchStart = m.end();
+ }
+ return hits;
+ }
+
+ private List findNestedIndependentBlocks(String text, int scopeStart, int scopeEnd) {
+ List ranges = new ArrayList<>();
+ Pattern nestedDef = Pattern.compile(
+ "\\b(struct|enum|interface)\\s+[A-Z][A-Za-z0-9]*\\s*(?:\\([^)]*\\))?\\s*(?:@0x[a-fA-F0-9]+)?\\s*\\{"
+ );
+ Matcher m = nestedDef.matcher(text);
+ int searchFrom = scopeStart + 1;
+
+ while (m.find(searchFrom)) {
+ int defStart = m.start();
+ if (defStart >= scopeEnd) break;
+ if (isInStringOrComment(text, scopeStart, defStart)) {
+ searchFrom = m.end();
+ continue;
+ }
+ int bracePos = text.indexOf('{', m.start());
+ if (bracePos < 0 || bracePos >= scopeEnd) { searchFrom = m.end(); continue; }
+ int closePos = findMatchingClose(text, bracePos);
+ if (closePos > 0 && closePos <= scopeEnd) {
+ ranges.add(new int[]{bracePos, closePos});
+ searchFrom = closePos + 1;
+ } else {
+ searchFrom = m.end();
+ }
+ }
+ return ranges;
+ }
+
+ private boolean isInSkipRange(int pos, List ranges) {
+ for (int[] range : ranges) {
+ if (pos > range[0] && pos < range[1]) return true;
+ }
+ return false;
+ }
+
+ private boolean isInStringOrComment(String text, int from, int pos) {
+ boolean inString = false;
+ boolean inComment = false;
+ for (int i = from; i < pos && i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (inComment) { if (c == '\n') inComment = false; continue; }
+ if (inString) {
+ if (c == '\\') { i++; continue; }
+ if (c == '"') inString = false;
+ continue;
+ }
+ if (c == '#') inComment = true;
+ else if (c == '"') inString = true;
+ }
+ return inString || inComment;
+ }
+
+ private static class OrdinalHit {
+ final int offset;
+ final int length;
+ final int value;
+ OrdinalHit(int offset, int length, int value) {
+ this.offset = offset;
+ this.length = length;
+ this.value = value;
+ }
+ }
+}
diff --git a/src/main/java/com/sercapnp/lang/CapnpCompletionContributor.java b/src/main/java/com/sercapnp/lang/CapnpCompletionContributor.java
new file mode 100644
index 0000000..b4dee04
--- /dev/null
+++ b/src/main/java/com/sercapnp/lang/CapnpCompletionContributor.java
@@ -0,0 +1,344 @@
+package com.sercapnp.lang;
+
+import com.intellij.codeInsight.completion.*;
+import com.intellij.codeInsight.lookup.LookupElementBuilder;
+import com.intellij.patterns.PlatformPatterns;
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.PsiFile;
+import com.intellij.util.ProcessingContext;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+
+/**
+ * Auto-completion for Cap'n Proto schema files.
+ *
+ * Context detection (by scanning backwards from caret):
+ * ':' → built-in types + user/imported types
+ * '=' → constant values
+ * '$' → annotation names
+ * top-level → declaration keywords
+ * inside {} → body-level keywords
+ * extends( → interface types
+ * annotation name( → target list
+ * List( / generic( → types
+ */
+public class CapnpCompletionContributor extends CompletionContributor {
+
+ private static final String[] BUILTIN_TYPES = {
+ "Void", "Bool",
+ "Int8", "Int16", "Int32", "Int64",
+ "UInt8", "UInt16", "UInt32", "UInt64",
+ "Float32", "Float64",
+ "Text", "Data",
+ "List", "AnyPointer"
+ };
+
+ private static final String[] TOP_LEVEL_KEYWORDS = {
+ "struct", "enum", "interface", "const", "using", "import", "annotation"
+ };
+
+ private static final String[] BODY_KEYWORDS = {
+ "union", "group", "struct", "enum", "interface", "const", "using"
+ };
+
+ private static final String[] ANNOTATION_TARGETS = {
+ "struct", "field", "union", "group", "enum", "enumerant",
+ "interface", "method", "param", "annotation", "const", "file", "*"
+ };
+
+ private static final String[] CONSTANT_VALUES = {
+ "true", "false", "void", "inf", "nan"
+ };
+
+ public CapnpCompletionContributor() {
+ extend(CompletionType.BASIC,
+ PlatformPatterns.psiElement().withLanguage(CapnpLanguage.INSTANCE),
+ new CompletionProvider() {
+ @Override
+ protected void addCompletions(@NotNull CompletionParameters parameters,
+ @NotNull ProcessingContext context,
+ @NotNull CompletionResultSet result) {
+ doAddCompletions(parameters, result);
+ }
+ }
+ );
+ }
+
+ private void doAddCompletions(@NotNull CompletionParameters parameters,
+ @NotNull CompletionResultSet result) {
+ PsiFile file = parameters.getOriginalFile();
+ int offset = parameters.getOffset();
+ String fileText = file.getText();
+
+ ContextKind ctx = analyzeContext(fileText, offset);
+
+ switch (ctx) {
+ case TYPE_POSITION:
+ addBuiltinTypes(result);
+ addUserDefinedTypes(result, file);
+ break;
+ case TOP_LEVEL:
+ addKeywords(result, TOP_LEVEL_KEYWORDS, "keyword");
+ break;
+ case BODY_LEVEL:
+ addKeywords(result, BODY_KEYWORDS, "keyword");
+ break;
+ case ANNOTATION_TARGET:
+ addKeywords(result, ANNOTATION_TARGETS, "target");
+ break;
+ case VALUE_POSITION:
+ addKeywords(result, CONSTANT_VALUES, "constant");
+ addUserDefinedConstants(result, file);
+ break;
+ case ANNOTATION_REF:
+ addAnnotationNames(result, file);
+ break;
+ case EXTENDS_TYPE:
+ addBuiltinTypes(result);
+ addUserDefinedTypes(result, file);
+ break;
+ default:
+ addBuiltinTypes(result);
+ addKeywords(result, TOP_LEVEL_KEYWORDS, "keyword");
+ addUserDefinedTypes(result, file);
+ break;
+ }
+ }
+
+ // ── Context detection ───────────────────────────────────────────────────────
+
+ private enum ContextKind {
+ TYPE_POSITION, TOP_LEVEL, BODY_LEVEL, ANNOTATION_TARGET,
+ VALUE_POSITION, ANNOTATION_REF, EXTENDS_TYPE, GENERIC
+ }
+
+ private ContextKind analyzeContext(String text, int offset) {
+ int pos = Math.min(offset, text.length()) - 1;
+
+ // Skip whitespace and partial identifier backwards
+ // First skip the partial identifier the user is currently typing
+ while (pos >= 0 && (Character.isLetterOrDigit(text.charAt(pos)) || text.charAt(pos) == '_')) {
+ pos--;
+ }
+ // Then skip whitespace
+ while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) {
+ pos--;
+ }
+
+ if (pos < 0) return ContextKind.TOP_LEVEL;
+
+ char prevChar = text.charAt(pos);
+
+ // After ':' → type position
+ if (prevChar == ':') return ContextKind.TYPE_POSITION;
+
+ // After '=' → value position
+ if (prevChar == '=') return ContextKind.VALUE_POSITION;
+
+ // After '$' → annotation reference
+ if (prevChar == '$') return ContextKind.ANNOTATION_REF;
+
+ // After '(' or ',' → check what kind of context
+ if (prevChar == '(' || prevChar == ',') {
+ String wordBefore = getWordBefore(text, pos - 1);
+ if ("extends".equals(wordBefore)) return ContextKind.EXTENDS_TYPE;
+ if ("List".equals(wordBefore)) return ContextKind.TYPE_POSITION;
+
+ // Check if inside annotation target list
+ if (prevChar == '(' && isAfterAnnotationDecl(text, pos)) {
+ return ContextKind.ANNOTATION_TARGET;
+ }
+ if (prevChar == ',' && isInAnnotationTargetList(text, pos)) {
+ return ContextKind.ANNOTATION_TARGET;
+ }
+
+ // If after '(' preceded by a capitalized name → likely generic param → type
+ if (wordBefore.length() > 0 && Character.isUpperCase(wordBefore.charAt(0))) {
+ return ContextKind.TYPE_POSITION;
+ }
+ }
+
+ int braceDepth = countBraceDepth(text, offset);
+
+ // After ';', '{', '}' → depends on depth
+ if (prevChar == ';' || prevChar == '{' || prevChar == '}') {
+ return braceDepth == 0 ? ContextKind.TOP_LEVEL : ContextKind.BODY_LEVEL;
+ }
+
+ // Line start
+ if (isAtLineStart(text, pos)) {
+ return braceDepth == 0 ? ContextKind.TOP_LEVEL : ContextKind.BODY_LEVEL;
+ }
+
+ // If inside braces, check if there's a preceding ':'
+ if (braceDepth > 0 && hasPrecedingColonOnStatement(text, pos)) {
+ return ContextKind.TYPE_POSITION;
+ }
+
+ return braceDepth > 0 ? ContextKind.BODY_LEVEL : ContextKind.GENERIC;
+ }
+
+ // ── Completion providers ────────────────────────────────────────────────────
+
+ private void addBuiltinTypes(@NotNull CompletionResultSet result) {
+ for (String type : BUILTIN_TYPES) {
+ LookupElementBuilder el = LookupElementBuilder.create(type)
+ .withTypeText("built-in", true)
+ .withBoldness(true);
+
+ if ("List".equals(type)) {
+ el = el.withTailText("(T)", true)
+ .withInsertHandler((ctx, item) -> {
+ ctx.getEditor().getDocument().insertString(ctx.getTailOffset(), "()");
+ ctx.getEditor().getCaretModel().moveToOffset(ctx.getTailOffset() - 1);
+ });
+ }
+
+ result.addElement(PrioritizedLookupElement.withPriority(el, 100));
+ }
+ }
+
+ private void addUserDefinedTypes(@NotNull CompletionResultSet result, PsiFile file) {
+ Set types = CapnpSchemaScanner.collectVisibleTypes(file);
+ for (String type : types) {
+ // Skip built-in type names to avoid duplicates
+ boolean isBuiltin = false;
+ for (String bt : BUILTIN_TYPES) {
+ if (bt.equals(type)) { isBuiltin = true; break; }
+ }
+ if (isBuiltin) continue;
+
+ LookupElementBuilder el = LookupElementBuilder.create(type)
+ .withTypeText("type", true);
+ result.addElement(PrioritizedLookupElement.withPriority(el, 80));
+ }
+ }
+
+ private void addKeywords(@NotNull CompletionResultSet result, String[] keywords, String typeText) {
+ for (String kw : keywords) {
+ LookupElementBuilder el = LookupElementBuilder.create(kw)
+ .withTypeText(typeText, true)
+ .withBoldness(true);
+
+ if ("struct".equals(kw) || "enum".equals(kw) || "interface".equals(kw)) {
+ el = el.withInsertHandler((ctx, item) -> {
+ ctx.getEditor().getDocument().insertString(ctx.getTailOffset(), " ");
+ ctx.getEditor().getCaretModel().moveToOffset(ctx.getTailOffset());
+ });
+ } else if ("import".equals(kw)) {
+ el = el.withInsertHandler((ctx, item) -> {
+ ctx.getEditor().getDocument().insertString(ctx.getTailOffset(), " \"\";");
+ ctx.getEditor().getCaretModel().moveToOffset(ctx.getTailOffset() - 2);
+ });
+ } else if ("const".equals(kw)) {
+ el = el.withInsertHandler((ctx, item) -> {
+ ctx.getEditor().getDocument().insertString(ctx.getTailOffset(), " ");
+ ctx.getEditor().getCaretModel().moveToOffset(ctx.getTailOffset());
+ });
+ }
+
+ result.addElement(PrioritizedLookupElement.withPriority(el, 90));
+ }
+ }
+
+ private void addUserDefinedConstants(@NotNull CompletionResultSet result, PsiFile file) {
+ CapnpSchemaScanner.ScanResult scan = CapnpSchemaScanner.scan(file.getText());
+ for (String c : scan.constants) {
+ LookupElementBuilder el = LookupElementBuilder.create("." + c)
+ .withPresentableText("." + c)
+ .withTypeText("const", true);
+ result.addElement(PrioritizedLookupElement.withPriority(el, 70));
+ }
+ }
+
+ private void addAnnotationNames(@NotNull CompletionResultSet result, PsiFile file) {
+ Set annots = CapnpSchemaScanner.collectVisibleAnnotations(file);
+ for (String name : annots) {
+ LookupElementBuilder el = LookupElementBuilder.create(name)
+ .withTypeText("annotation", true);
+ result.addElement(PrioritizedLookupElement.withPriority(el, 85));
+ }
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ private int countBraceDepth(String text, int offset) {
+ int depth = 0;
+ boolean inString = false;
+ boolean inComment = false;
+
+ for (int i = 0; i < offset && i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (inComment) {
+ if (c == '\n') inComment = false;
+ continue;
+ }
+ if (inString) {
+ if (c == '\\') { i++; continue; }
+ if (c == '"') inString = false;
+ continue;
+ }
+ if (c == '#') { inComment = true; continue; }
+ if (c == '"') { inString = true; continue; }
+ if (c == '{') depth++;
+ if (c == '}') depth--;
+ }
+ return depth;
+ }
+
+ private String getWordBefore(String text, int pos) {
+ while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) pos--;
+ if (pos < 0) return "";
+ int end = pos + 1;
+ while (pos >= 0 && Character.isLetterOrDigit(text.charAt(pos))) pos--;
+ return text.substring(pos + 1, end);
+ }
+
+ private boolean isAtLineStart(String text, int pos) {
+ while (pos >= 0 && (text.charAt(pos) == ' ' || text.charAt(pos) == '\t')) pos--;
+ return pos < 0 || text.charAt(pos) == '\n';
+ }
+
+ /**
+ * Check if position is right after 'annotation Name (' pattern.
+ */
+ private boolean isAfterAnnotationDecl(String text, int parenPos) {
+ // Go back from '(' to find the pattern: annotation
+ String before = "";
+ int lineStart = text.lastIndexOf('\n', parenPos - 1) + 1;
+ if (parenPos > lineStart) {
+ before = text.substring(lineStart, parenPos).trim();
+ }
+ return before.matches(".*annotation\\s+[a-zA-Z][a-zA-Z0-9]*\\s*(?:@0x[a-fA-F0-9]+\\s*)?");
+ }
+
+ /**
+ * Check if we're inside an annotation target list by finding unmatched '('.
+ */
+ private boolean isInAnnotationTargetList(String text, int pos) {
+ int parenDepth = 0;
+ for (int i = pos; i >= 0; i--) {
+ char c = text.charAt(i);
+ if (c == ')') parenDepth++;
+ else if (c == '(') {
+ if (parenDepth == 0) return isAfterAnnotationDecl(text, i);
+ parenDepth--;
+ }
+ else if (c == '{' || c == '}') break;
+ }
+ return false;
+ }
+
+ /**
+ * Check if there's a ':' on the current statement (between last ';'/'{' and pos).
+ */
+ private boolean hasPrecedingColonOnStatement(String text, int pos) {
+ for (int i = pos; i >= 0; i--) {
+ char c = text.charAt(i);
+ if (c == ':') return true;
+ if (c == ';' || c == '{' || c == '}') return false;
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/sercapnp/lang/CapnpGotoDeclarationHandler.java b/src/main/java/com/sercapnp/lang/CapnpGotoDeclarationHandler.java
new file mode 100644
index 0000000..2846218
--- /dev/null
+++ b/src/main/java/com/sercapnp/lang/CapnpGotoDeclarationHandler.java
@@ -0,0 +1,223 @@
+package com.sercapnp.lang;
+
+import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler;
+import com.intellij.openapi.editor.Editor;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.PsiManager;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Handles Ctrl+Click (Go to Declaration) for Cap'n Proto type references.
+ *
+ * When the user Ctrl+Clicks on a type name like "PersonInfo" or "StatusCode",
+ * this handler finds its struct/enum/interface definition in:
+ * 1. The current file
+ * 2. Imported .capnp files
+ * and navigates the editor to it.
+ */
+public class CapnpGotoDeclarationHandler implements GotoDeclarationHandler {
+
+ // Pattern to find a type definition: struct/enum/interface Name
+ // Captures the entire line so we can get exact offset
+ private static final Pattern TYPE_DEF_LINE = Pattern.compile(
+ "^(\\s*)(struct|enum|interface)\\s+(%s)\\b",
+ Pattern.MULTILINE
+ );
+
+ // Pattern to find: const name
+ private static final Pattern CONST_DEF_LINE = Pattern.compile(
+ "^(\\s*)const\\s+(%s)\\b",
+ Pattern.MULTILINE
+ );
+
+ // Pattern to find: annotation name
+ private static final Pattern ANNOTATION_DEF_LINE = Pattern.compile(
+ "^(\\s*)annotation\\s+(%s)\\b",
+ Pattern.MULTILINE
+ );
+
+ // Pattern to find enum enumerant: name @N
+ private static final Pattern ENUMERANT_LINE = Pattern.compile(
+ "^(\\s+)(%s)\\s+@\\d+",
+ Pattern.MULTILINE
+ );
+
+ @Override
+ public PsiElement @Nullable [] getGotoDeclarationTargets(@Nullable PsiElement sourceElement,
+ int offset,
+ Editor editor) {
+ if (sourceElement == null) return null;
+
+ PsiFile file = sourceElement.getContainingFile();
+ if (file == null || !(file instanceof CapnpFile)) return null;
+
+ // Get the identifier text under the cursor
+ String name = getIdentifierAt(file.getText(), offset);
+ if (name == null || name.isEmpty()) return null;
+
+ // Skip built-in types — no definition to navigate to
+ if (isBuiltinType(name)) return null;
+
+ Project project = file.getProject();
+
+ // 1. Search in current file
+ PsiElement found = findDefinition(file, name);
+ if (found != null) return new PsiElement[]{found};
+
+ // 2. Search in imported files
+ CapnpSchemaScanner.ScanResult scan = CapnpSchemaScanner.scan(file.getText());
+
+ VirtualFile vFile = file.getVirtualFile();
+ if (vFile == null && file.getOriginalFile() != null) {
+ vFile = file.getOriginalFile().getVirtualFile();
+ }
+
+ for (CapnpSchemaScanner.ImportInfo imp : scan.imports) {
+ VirtualFile imported = CapnpSchemaScanner.resolveImport(vFile, imp.path, project);
+ if (imported == null) continue;
+
+ PsiFile importedPsi = PsiManager.getInstance(project).findFile(imported);
+ if (importedPsi == null) continue;
+
+ // If the import has an alias and the name matches the alias,
+ // and there's a specific type, resolve to that type
+ if (imp.alias != null && imp.alias.equals(name) && imp.specificType != null) {
+ PsiElement target = findDefinition(importedPsi, imp.specificType);
+ if (target != null) return new PsiElement[]{target};
+ }
+
+ // Try finding the name directly in the imported file
+ PsiElement target = findDefinition(importedPsi, name);
+ if (target != null) return new PsiElement[]{target};
+ }
+
+ // 3. Fallback: search all .capnp files in project
+ for (VirtualFile capnpFile : com.intellij.psi.search.FileTypeIndex.getFiles(
+ CapnpFileType.INSTANCE,
+ com.intellij.psi.search.GlobalSearchScope.allScope(project))) {
+
+ if (capnpFile.equals(file.getVirtualFile())) continue;
+
+ PsiFile psi = PsiManager.getInstance(project).findFile(capnpFile);
+ if (psi == null) continue;
+
+ PsiElement target = findDefinition(psi, name);
+ if (target != null) return new PsiElement[]{target};
+ }
+
+ return null;
+ }
+
+ /**
+ * Find the PsiElement at the definition of 'name' in the given file.
+ * Returns the PsiElement at the name's offset, or null.
+ */
+ private PsiElement findDefinition(PsiFile file, String name) {
+ String text = file.getText();
+ String escaped = Pattern.quote(name);
+
+ // Try struct/enum/interface
+ int nameOffset = findNameOffset(text, TYPE_DEF_LINE, escaped, name);
+ if (nameOffset >= 0) {
+ return file.findElementAt(nameOffset);
+ }
+
+ // Try const
+ nameOffset = findNameOffset(text, CONST_DEF_LINE, escaped, name);
+ if (nameOffset >= 0) {
+ return file.findElementAt(nameOffset);
+ }
+
+ // Try annotation
+ nameOffset = findNameOffset(text, ANNOTATION_DEF_LINE, escaped, name);
+ if (nameOffset >= 0) {
+ return file.findElementAt(nameOffset);
+ }
+
+ // Try enum enumerant
+ nameOffset = findNameOffset(text, ENUMERANT_LINE, escaped, name);
+ if (nameOffset >= 0) {
+ return file.findElementAt(nameOffset);
+ }
+
+ return null;
+ }
+
+ /**
+ * Find the character offset of 'name' in a pattern match.
+ *
+ * @param text File text
+ * @param template Pattern template with %s placeholder for the name
+ * @param escaped Regex-escaped name
+ * @param name Raw name string
+ * @return offset of the name, or -1
+ */
+ private int findNameOffset(String text, Pattern template, String escaped, String name) {
+ // Build the actual pattern by formatting the name into the template
+ Pattern pattern = Pattern.compile(
+ String.format(template.pattern(), escaped),
+ template.flags()
+ );
+ Matcher m = pattern.matcher(text);
+ if (m.find()) {
+ // Find the exact position of the name within the match
+ int matchStart = m.start();
+ int namePos = text.indexOf(name, matchStart);
+ if (namePos >= matchStart && namePos <= m.end()) {
+ return namePos;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Extract the identifier at the given offset.
+ */
+ private String getIdentifierAt(String text, int offset) {
+ if (offset < 0 || offset >= text.length()) return null;
+
+ // Find word boundaries
+ int start = offset;
+ int end = offset;
+
+ while (start > 0 && isIdentChar(text.charAt(start - 1))) start--;
+ while (end < text.length() && isIdentChar(text.charAt(end))) end++;
+
+ if (start == end) return null;
+
+ String word = text.substring(start, end);
+
+ // Must start with a letter
+ if (word.isEmpty() || !Character.isLetter(word.charAt(0))) return null;
+
+ return word;
+ }
+
+ private boolean isIdentChar(char c) {
+ return Character.isLetterOrDigit(c) || c == '_';
+ }
+
+ private boolean isBuiltinType(String name) {
+ switch (name) {
+ case "Void": case "Bool":
+ case "Int8": case "Int16": case "Int32": case "Int64":
+ case "UInt8": case "UInt16": case "UInt32": case "UInt64":
+ case "Float32": case "Float64":
+ case "Text": case "Data": case "List": case "AnyPointer":
+ // Keywords
+ case "struct": case "enum": case "interface": case "union":
+ case "group": case "import": case "using": case "const":
+ case "annotation": case "extends":
+ case "true": case "false": case "void": case "inf": case "nan":
+ return true;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/com/sercapnp/lang/CapnpOrdinalTypedHandler.java b/src/main/java/com/sercapnp/lang/CapnpOrdinalTypedHandler.java
new file mode 100644
index 0000000..2804576
--- /dev/null
+++ b/src/main/java/com/sercapnp/lang/CapnpOrdinalTypedHandler.java
@@ -0,0 +1,299 @@
+package com.sercapnp.lang;
+
+import com.intellij.codeInsight.editorActions.TypedHandlerDelegate;
+import com.intellij.openapi.editor.Document;
+import com.intellij.openapi.editor.Editor;
+import com.intellij.openapi.fileTypes.FileType;
+import com.intellij.openapi.project.Project;
+import com.intellij.psi.PsiFile;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Auto-inserts the next ordinal number when '@' is typed in a field/enumerant position.
+ *
+ * Behavior:
+ * - User types field name, then '@'
+ * - Handler detects we're inside a struct/enum/interface/union block
+ * - Finds the highest existing @N ordinal in the current scope
+ * - Replaces the typed '@' with '@(N+1)'
+ *
+ * Does NOT trigger when:
+ * - At line start (might be a file ID like @0xABCD)
+ * - After '=' or ':' (value/type context)
+ * - Outside of any block (brace depth 0)
+ * - Inside a comment or string
+ */
+public class CapnpOrdinalTypedHandler extends TypedHandlerDelegate {
+
+ // Matches @N ordinals in text
+ private static final Pattern ORDINAL_PATTERN = Pattern.compile("@(\\d+)");
+
+ @NotNull
+ @Override
+ public Result charTyped(char c, @NotNull Project project, @NotNull Editor editor, @NotNull PsiFile file) {
+ if (c != '@') return Result.CONTINUE;
+ if (!(file instanceof CapnpFile)) return Result.CONTINUE;
+
+ Document doc = editor.getDocument();
+ int offset = editor.getCaretModel().getOffset();
+ String text = doc.getText();
+
+ // The '@' has already been inserted at offset-1
+ int atPos = offset - 1;
+ if (atPos < 0) return Result.CONTINUE;
+
+ // Check context: should we auto-insert ordinal?
+ if (!shouldAutoInsert(text, atPos)) {
+ return Result.CONTINUE;
+ }
+
+ // Find the enclosing block boundaries
+ int blockStart = findBlockStart(text, atPos);
+ if (blockStart < 0) return Result.CONTINUE;
+
+ int blockEnd = findBlockEnd(text, atPos);
+ if (blockEnd < 0) blockEnd = text.length();
+
+ // Find highest ordinal in the current block
+ int maxOrdinal = findMaxOrdinal(text, blockStart, blockEnd);
+ int nextOrdinal = maxOrdinal + 1;
+
+ // Insert the ordinal number right after '@'
+ String ordinalStr = Integer.toString(nextOrdinal);
+ doc.insertString(offset, ordinalStr);
+
+ // Move caret after the inserted number
+ editor.getCaretModel().moveToOffset(offset + ordinalStr.length());
+
+ return Result.STOP;
+ }
+
+ /**
+ * Determine if we should auto-insert an ordinal at this position.
+ */
+ private boolean shouldAutoInsert(String text, int atPos) {
+ // Must be inside a block (brace depth > 0)
+ int braceDepth = countBraceDepth(text, atPos);
+ if (braceDepth <= 0) return false;
+
+ // Check what's before the '@' (skip whitespace)
+ int pos = atPos - 1;
+ while (pos >= 0 && (text.charAt(pos) == ' ' || text.charAt(pos) == '\t')) {
+ pos--;
+ }
+
+ if (pos < 0) return false;
+
+ char prevChar = text.charAt(pos);
+
+ // The '@' should come after an identifier (field name / enum value / method name)
+ if (!Character.isLetterOrDigit(prevChar) && prevChar != '_') {
+ return false;
+ }
+
+ // Walk back through the identifier
+ while (pos >= 0 && (Character.isLetterOrDigit(text.charAt(pos)) || text.charAt(pos) == '_')) {
+ pos--;
+ }
+
+ // Now pos is at the character before the identifier.
+ // Skip whitespace again
+ while (pos >= 0 && (text.charAt(pos) == ' ' || text.charAt(pos) == '\t')) {
+ pos--;
+ }
+
+ if (pos < 0) return false;
+
+ char beforeIdent = text.charAt(pos);
+
+ // Valid contexts for ordinal:
+ // - After newline or '{' → field/enumerant at start of line (inside block)
+ // - After ';' → next field
+ // These indicate we're at a field/enumerant declaration position
+ if (beforeIdent == '\n' || beforeIdent == '\r' || beforeIdent == '{' || beforeIdent == ';') {
+ return true;
+ }
+
+ // Also check: if the identifier is preceded by ')' that could be method params
+ // e.g., method name @N (...) -> (...)
+ // But typically in capnp: methodName @N (params) -> (results)
+ // so '@' comes right after the name, same as fields
+
+ return false;
+ }
+
+ /**
+ * Find the start of the enclosing block (the '{' that opens the current scope).
+ */
+ private int findBlockStart(String text, int pos) {
+ int depth = 0;
+ boolean inString = false;
+ boolean inComment = false;
+
+ for (int i = pos - 1; i >= 0; i--) {
+ char c = text.charAt(i);
+
+ // Handle reverse scanning through comments (tricky — skip to line start for #)
+ if (c == '\n') {
+ inComment = false;
+ continue;
+ }
+
+ if (inString) {
+ if (c == '"' && (i == 0 || text.charAt(i - 1) != '\\')) {
+ inString = false;
+ }
+ continue;
+ }
+
+ // Check if this line has a # comment — if so, skip if we're after #
+ if (c == '#') {
+ inComment = true;
+ continue;
+ }
+ if (inComment) continue;
+
+ if (c == '"') {
+ inString = true;
+ continue;
+ }
+
+ if (c == '}') {
+ depth++;
+ } else if (c == '{') {
+ if (depth == 0) {
+ return i;
+ }
+ depth--;
+ }
+ }
+
+ return -1;
+ }
+
+ /**
+ * Find the end of the enclosing block (the '}' that closes the current scope).
+ */
+ private int findBlockEnd(String text, int pos) {
+ int depth = 0;
+ boolean inString = false;
+ boolean inComment = false;
+
+ for (int i = pos; i < text.length(); i++) {
+ char c = text.charAt(i);
+
+ if (inComment) {
+ if (c == '\n') inComment = false;
+ continue;
+ }
+
+ if (inString) {
+ if (c == '\\') { i++; continue; }
+ if (c == '"') inString = false;
+ continue;
+ }
+
+ if (c == '#') { inComment = true; continue; }
+ if (c == '"') { inString = true; continue; }
+
+ if (c == '{') {
+ depth++;
+ } else if (c == '}') {
+ if (depth == 0) return i;
+ depth--;
+ }
+ }
+
+ return -1;
+ }
+
+ /**
+ * Find the maximum ordinal @N within the given text range.
+ * Only looks at the current block level (skips nested blocks).
+ */
+ private int findMaxOrdinal(String text, int start, int end) {
+ int maxOrdinal = -1;
+ int depth = 0;
+ boolean inString = false;
+ boolean inComment = false;
+
+ // Start after the opening '{'
+ int searchStart = start + 1;
+
+ for (int i = searchStart; i < end && i < text.length(); i++) {
+ char c = text.charAt(i);
+
+ if (inComment) {
+ if (c == '\n') inComment = false;
+ continue;
+ }
+
+ if (inString) {
+ if (c == '\\') { i++; continue; }
+ if (c == '"') inString = false;
+ continue;
+ }
+
+ if (c == '#') { inComment = true; continue; }
+ if (c == '"') { inString = true; continue; }
+
+ if (c == '{') { depth++; continue; }
+ if (c == '}') { depth--; continue; }
+
+ // Only look at ordinals at the current block level (depth 0)
+ // But ALSO look inside unions/groups at depth 1,
+ // because capnp ordinals share the same numbering space within a struct
+ if (depth <= 1 && c == '@' && i + 1 < end) {
+ // Check if this is @N (ordinal) not @0x... (type ID)
+ int numStart = i + 1;
+ if (numStart < end && Character.isDigit(text.charAt(numStart))) {
+ int numEnd = numStart;
+ while (numEnd < end && Character.isDigit(text.charAt(numEnd))) {
+ numEnd++;
+ }
+ // Make sure it's not @0x... (hex ID)
+ if (numEnd < end && (text.charAt(numEnd) == 'x' || text.charAt(numEnd) == 'X')) {
+ continue; // This is a hex ID, skip
+ }
+ try {
+ int ordinal = Integer.parseInt(text.substring(numStart, numEnd));
+ if (ordinal > maxOrdinal) {
+ maxOrdinal = ordinal;
+ }
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+ }
+ }
+
+ return maxOrdinal;
+ }
+
+ private int countBraceDepth(String text, int offset) {
+ int depth = 0;
+ boolean inString = false;
+ boolean inComment = false;
+
+ for (int i = 0; i < offset && i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (inComment) {
+ if (c == '\n') inComment = false;
+ continue;
+ }
+ if (inString) {
+ if (c == '\\') { i++; continue; }
+ if (c == '"') inString = false;
+ continue;
+ }
+ if (c == '#') { inComment = true; continue; }
+ if (c == '"') { inString = true; continue; }
+ if (c == '{') depth++;
+ if (c == '}') depth--;
+ }
+ return depth;
+ }
+}
diff --git a/src/main/java/com/sercapnp/lang/CapnpSchemaScanner.java b/src/main/java/com/sercapnp/lang/CapnpSchemaScanner.java
new file mode 100644
index 0000000..f56515e
--- /dev/null
+++ b/src/main/java/com/sercapnp/lang/CapnpSchemaScanner.java
@@ -0,0 +1,436 @@
+package com.sercapnp.lang;
+
+import com.intellij.openapi.diagnostic.Logger;
+import com.intellij.openapi.module.Module;
+import com.intellij.openapi.module.ModuleManager;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.roots.ModuleRootManager;
+import com.intellij.openapi.roots.ProjectRootManager;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.PsiManager;
+import com.intellij.psi.search.FileTypeIndex;
+import com.intellij.psi.search.GlobalSearchScope;
+
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Scans .capnp files to extract type definitions and resolve imports.
+ * Works without PSI tree — uses regex on file text.
+ */
+public class CapnpSchemaScanner {
+
+ private static final Logger LOG = Logger.getInstance(CapnpSchemaScanner.class);
+
+ // ── Patterns ────────────────────────────────────────────────────────────────
+
+ // Top-level type defs (0-4 spaces indent)
+ private static final Pattern TYPE_DEF_PATTERN = Pattern.compile(
+ "^\\s{0,4}(struct|enum|interface)\\s+([A-Z][A-Za-z0-9]*)\\b",
+ Pattern.MULTILINE
+ );
+
+ // Nested type defs (2+ spaces or tab indent)
+ private static final Pattern NESTED_TYPE_PATTERN = Pattern.compile(
+ "^(?:\\s{2,}|\\t+)(struct|enum|interface)\\s+([A-Z][A-Za-z0-9]*)\\b",
+ Pattern.MULTILINE
+ );
+
+ private static final Pattern CONST_DEF_PATTERN = Pattern.compile(
+ "^\\s*const\\s+([a-zA-Z][A-Za-z0-9]*)\\s*:",
+ Pattern.MULTILINE
+ );
+
+ private static final Pattern ANNOTATION_DEF_PATTERN = Pattern.compile(
+ "^\\s*annotation\\s+([a-zA-Z][A-Za-z0-9]*)\\s",
+ Pattern.MULTILINE
+ );
+
+ // import "path.capnp"
+ private static final Pattern IMPORT_PATTERN = Pattern.compile(
+ "import\\s+\"([^\"]+)\"",
+ Pattern.MULTILINE
+ );
+
+ // using Alias = import "path.capnp".Type
+ private static final Pattern USING_IMPORT_PATTERN = Pattern.compile(
+ "^\\s*using\\s+([A-Z][A-Za-z0-9]*)\\s*=\\s*import\\s+\"([^\"]+)\"(?:\\s*\\.\\s*([A-Z][A-Za-z0-9]*))?",
+ Pattern.MULTILINE
+ );
+
+ // using import "path.capnp".Type;
+ private static final Pattern USING_IMPORT_DIRECT_PATTERN = Pattern.compile(
+ "^\\s*using\\s+import\\s+\"([^\"]+)\"\\s*\\.\\s*([A-Z][A-Za-z0-9]*)",
+ Pattern.MULTILINE
+ );
+
+ // using Alias = Scope.Type
+ private static final Pattern USING_ALIAS_PATTERN = Pattern.compile(
+ "^\\s*using\\s+([A-Z][A-Za-z0-9]*)\\s*=\\s*([A-Z][A-Za-z0-9.]+)",
+ Pattern.MULTILINE
+ );
+
+ // ── Data classes ────────────────────────────────────────────────────────────
+
+ public static class ScanResult {
+ public final List types = new ArrayList<>();
+ public final List constants = new ArrayList<>();
+ public final List annotations = new ArrayList<>();
+ public final List imports = new ArrayList<>();
+ public final List aliases = new ArrayList<>();
+
+ public List allTypeNames() {
+ List names = new ArrayList<>();
+ for (TypeInfo t : types) names.add(t.name);
+ return names;
+ }
+ }
+
+ public static class TypeInfo {
+ public final String kind;
+ public final String name;
+ public final boolean nested;
+
+ public TypeInfo(String kind, String name, boolean nested) {
+ this.kind = kind;
+ this.name = name;
+ this.nested = nested;
+ }
+ }
+
+ public static class ImportInfo {
+ public final String path;
+ public final String alias;
+ public final String specificType;
+
+ public ImportInfo(String path, String alias, String specificType) {
+ this.path = path;
+ this.alias = alias;
+ this.specificType = specificType;
+ }
+ }
+
+ public static class AliasInfo {
+ public final String alias;
+ public final String target;
+
+ public AliasInfo(String alias, String target) {
+ this.alias = alias;
+ this.target = target;
+ }
+ }
+
+ // ── Scanning ────────────────────────────────────────────────────────────────
+
+ public static ScanResult scan(String text) {
+ ScanResult result = new ScanResult();
+ if (text == null || text.isEmpty()) return result;
+
+ String stripped = stripComments(text);
+ Set seenNames = new HashSet<>();
+
+ // Top-level types
+ Matcher m = TYPE_DEF_PATTERN.matcher(stripped);
+ while (m.find()) {
+ String name = m.group(2);
+ if (seenNames.add(name)) {
+ result.types.add(new TypeInfo(m.group(1), name, false));
+ }
+ }
+
+ // Nested types
+ m = NESTED_TYPE_PATTERN.matcher(stripped);
+ while (m.find()) {
+ String name = m.group(2);
+ if (seenNames.add(name)) {
+ result.types.add(new TypeInfo(m.group(1), name, true));
+ }
+ }
+
+ // Constants
+ m = CONST_DEF_PATTERN.matcher(stripped);
+ while (m.find()) result.constants.add(m.group(1));
+
+ // Annotations
+ m = ANNOTATION_DEF_PATTERN.matcher(stripped);
+ while (m.find()) result.annotations.add(m.group(1));
+
+ // === Imports ===
+ Set seenImportPaths = new HashSet<>();
+
+ // using Alias = import "path".Type
+ m = USING_IMPORT_PATTERN.matcher(stripped);
+ while (m.find()) {
+ String path = m.group(2);
+ seenImportPaths.add(path);
+ result.imports.add(new ImportInfo(path, m.group(1), m.group(3)));
+ }
+
+ // using import "path".Type
+ m = USING_IMPORT_DIRECT_PATTERN.matcher(stripped);
+ while (m.find()) {
+ String path = m.group(1);
+ String typeName = m.group(2);
+ if (seenImportPaths.add(path + "." + typeName)) {
+ result.imports.add(new ImportInfo(path, typeName, typeName));
+ result.aliases.add(new AliasInfo(typeName, typeName));
+ }
+ }
+
+ // Plain import "path"
+ m = IMPORT_PATTERN.matcher(stripped);
+ while (m.find()) {
+ String path = m.group(1);
+ if (seenImportPaths.add(path)) {
+ result.imports.add(new ImportInfo(path, null, null));
+ }
+ }
+
+ // using Alias = Scope.Type (non-import)
+ m = USING_ALIAS_PATTERN.matcher(stripped);
+ while (m.find()) {
+ String target = m.group(2);
+ if (!target.contains("import")) {
+ result.aliases.add(new AliasInfo(m.group(1), target));
+ }
+ }
+
+ return result;
+ }
+
+ // ── Import Resolution (multi-strategy) ──────────────────────────────────────
+
+ /**
+ * Resolve an import path. Tries multiple strategies:
+ * 1. Relative to the importing file's directory
+ * 2. Relative to project content roots
+ * 3. Relative to module source roots
+ * 4. Filename-based search across all project .capnp files
+ */
+ public static VirtualFile resolveImport(VirtualFile importingFile, String importPath, Project project) {
+ if (importPath == null || importPath.isEmpty() || project == null) return null;
+
+ // Strategy 1: Relative to current file's directory
+ if (importingFile != null) {
+ VirtualFile dir = importingFile.getParent();
+ if (dir != null) {
+ VirtualFile found = dir.findFileByRelativePath(importPath);
+ if (found != null && found.isValid()) return found;
+ }
+ }
+
+ // Normalize path for remaining strategies
+ String searchPath = importPath.startsWith("/") ? importPath.substring(1) : importPath;
+
+ // Strategy 2: Relative to project content roots
+ for (VirtualFile root : ProjectRootManager.getInstance(project).getContentRoots()) {
+ VirtualFile found = root.findFileByRelativePath(searchPath);
+ if (found != null && found.isValid()) return found;
+ }
+
+ // Strategy 3: Relative to module source roots
+ for (Module module : ModuleManager.getInstance(project).getModules()) {
+ for (VirtualFile sourceRoot : ModuleRootManager.getInstance(module).getSourceRoots()) {
+ VirtualFile found = sourceRoot.findFileByRelativePath(searchPath);
+ if (found != null && found.isValid()) return found;
+ }
+ }
+
+ // Strategy 4: Search all .capnp files by filename / path suffix
+ String targetFilename = importPath;
+ int lastSlash = targetFilename.lastIndexOf('/');
+ if (lastSlash >= 0) targetFilename = targetFilename.substring(lastSlash + 1);
+
+ Collection allFiles = FileTypeIndex.getFiles(
+ CapnpFileType.INSTANCE,
+ GlobalSearchScope.allScope(project) // allScope includes libraries
+ );
+
+ // Try path suffix match first (more precise)
+ for (VirtualFile file : allFiles) {
+ if (file.getPath().endsWith(searchPath)) return file;
+ }
+
+ // Try filename match (less precise but catches more)
+ String finalTarget = targetFilename;
+ for (VirtualFile file : allFiles) {
+ if (file.getName().equals(finalTarget)) return file;
+ }
+
+ LOG.debug("Could not resolve capnp import: " + importPath +
+ " from " + (importingFile != null ? importingFile.getPath() : "null"));
+ return null;
+ }
+
+ // ── High-level collection ───────────────────────────────────────────────────
+
+ public static ScanResult scanFile(VirtualFile file, Project project) {
+ if (file == null || !file.isValid() || project == null) return null;
+ PsiFile psiFile = PsiManager.getInstance(project).findFile(file);
+ if (psiFile == null) return null;
+ return scan(psiFile.getText());
+ }
+
+ /**
+ * Collect all type names visible from the given file.
+ * Resolves imports, follows one level deep.
+ * Falls back to project-wide search if no imports resolve.
+ */
+ public static Set collectVisibleTypes(PsiFile currentFile) {
+ Set types = new LinkedHashSet<>();
+ if (currentFile == null) return types;
+
+ Project project = currentFile.getProject();
+ ScanResult current = scan(currentFile.getText());
+
+ // 1. Current file types
+ for (TypeInfo t : current.types) types.add(t.name);
+
+ // 2. Aliases
+ for (AliasInfo a : current.aliases) types.add(a.alias);
+
+ // 3. Resolve imports
+ VirtualFile vFile = currentFile.getVirtualFile();
+ if (vFile == null && currentFile.getOriginalFile() != null) {
+ vFile = currentFile.getOriginalFile().getVirtualFile();
+ }
+
+ boolean anyImportResolved = false;
+
+ for (ImportInfo imp : current.imports) {
+ VirtualFile imported = resolveImport(vFile, imp.path, project);
+ if (imported == null) continue;
+
+ anyImportResolved = true;
+ ScanResult importedScan = scanFile(imported, project);
+ if (importedScan == null) continue;
+
+ if (imp.specificType != null) {
+ // using import "...".SpecificType — only that type
+ for (TypeInfo t : importedScan.types) {
+ if (t.name.equals(imp.specificType)) {
+ types.add(imp.alias != null ? imp.alias : t.name);
+ break;
+ }
+ }
+ } else {
+ // Whole-file import — add all top-level types
+ for (TypeInfo t : importedScan.types) {
+ if (!t.nested) types.add(t.name);
+ }
+ // If there's a module alias, add it too
+ if (imp.alias != null) types.add(imp.alias);
+ }
+ }
+
+ // 4. Fallback: if imports exist but none resolved → project-wide
+ if (!current.imports.isEmpty() && !anyImportResolved) {
+ types.addAll(collectProjectTypes(project));
+ }
+
+ return types;
+ }
+
+ /**
+ * Collect all type names from ALL .capnp files in the project.
+ */
+ public static Set collectProjectTypes(Project project) {
+ Set types = new LinkedHashSet<>();
+ Collection files = FileTypeIndex.getFiles(
+ CapnpFileType.INSTANCE,
+ GlobalSearchScope.allScope(project)
+ );
+ for (VirtualFile file : files) {
+ ScanResult scan = scanFile(file, project);
+ if (scan != null) {
+ for (TypeInfo t : scan.types) {
+ if (!t.nested) types.add(t.name);
+ }
+ }
+ }
+ return types;
+ }
+
+ /**
+ * Collect all annotation names visible from the given file.
+ */
+ public static Set collectVisibleAnnotations(PsiFile currentFile) {
+ Set annots = new LinkedHashSet<>();
+ if (currentFile == null) return annots;
+
+ Project project = currentFile.getProject();
+ ScanResult current = scan(currentFile.getText());
+ annots.addAll(current.annotations);
+
+ VirtualFile vFile = currentFile.getVirtualFile();
+ if (vFile == null && currentFile.getOriginalFile() != null) {
+ vFile = currentFile.getOriginalFile().getVirtualFile();
+ }
+
+ for (ImportInfo imp : current.imports) {
+ VirtualFile imported = resolveImport(vFile, imp.path, project);
+ ScanResult importedScan = scanFile(imported, project);
+ if (importedScan != null) annots.addAll(importedScan.annotations);
+ }
+
+ return annots;
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ private static String stripComments(String text) {
+ StringBuilder sb = new StringBuilder(text.length());
+ boolean inString = false;
+
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (inString) {
+ sb.append(c);
+ if (c == '\\' && i + 1 < text.length()) {
+ sb.append(text.charAt(++i));
+ } else if (c == '"') {
+ inString = false;
+ }
+ continue;
+ }
+ if (c == '"') {
+ inString = true;
+ sb.append(c);
+ } else if (c == '#') {
+ while (i < text.length() && text.charAt(i) != '\n') i++;
+ if (i < text.length()) sb.append('\n');
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ public static List getEnumValues(String text, String enumName) {
+ List values = new ArrayList<>();
+ Pattern p = Pattern.compile(
+ "enum\\s+" + Pattern.quote(enumName) + "\\s*(?:@0x[a-fA-F0-9]+)?\\s*\\{([^}]*)\\}",
+ Pattern.DOTALL);
+ Matcher m = p.matcher(text);
+ if (m.find()) {
+ Matcher em = Pattern.compile("([a-zA-Z][a-zA-Z0-9]*)\\s+@\\d+").matcher(m.group(1));
+ while (em.find()) values.add(em.group(1));
+ }
+ return values;
+ }
+
+ public static List getStructFields(String text, String structName) {
+ List fields = new ArrayList<>();
+ Pattern p = Pattern.compile(
+ "struct\\s+" + Pattern.quote(structName) + "\\s*(?:\\([^)]*\\))?\\s*(?:@0x[a-fA-F0-9]+)?\\s*\\{([^}]*)\\}",
+ Pattern.DOTALL);
+ Matcher m = p.matcher(text);
+ if (m.find()) {
+ Matcher fm = Pattern.compile("([a-z][a-zA-Z0-9]*)\\s+@\\d+\\s*:").matcher(m.group(1));
+ while (fm.find()) fields.add(fm.group(1));
+ }
+ return fields;
+ }
+}
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index 20af87e..de0f217 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -1,7 +1,7 @@
com.sercapnp
SerCapnp
- 3.0
+ 4.1
xmonader
Full-featured Cap'n Proto (.capnp) schema language support for IntelliJ-based IDEs.
Features
+ - Go to definition — Ctrl+Click on any type name to jump to its struct/enum/interface definition, even across imported files
+ - Shift ordinals down — insert a field in the middle: all subsequent ordinals auto-increment (Ctrl+Alt+A, S)
+ - Shift ordinals up — remove a field from the middle: close the ordinal gap (Ctrl+Alt+A, U)
+ - Ordinal auto-increment — type
@ after a field name and the next ordinal number is inserted automatically
+ - Code completion — built-in types (UInt64, List, Text...), keywords, user-defined types, imported types, annotation targets, constants
- Syntax highlighting — keywords, types, identifiers, strings, numbers, constants, comments, unique IDs, ordinals
- Bracket matching — auto-matching for
{}, [], ()
- Comment toggling — use Ctrl+/ to toggle
# line comments
@@ -21,6 +26,9 @@
+ - 4.1: Shift ordinals down/up — insert or remove fields in the middle with automatic renumbering (Ctrl+Alt+A, S / U)
+ - 4.0: Go to definition (Ctrl+Click cross-file navigation); ordinal auto-increment (@N auto-numbering)
+ - 4.0: Code completion — built-in types, keywords, user-defined types from current file and imports, annotation names/targets, constants, import paths
- 3.0: Major update — string, number, constant highlighting; comment toggling; brace matching; code folding; AnyPointer type; fixed lexer
- 2.0: Updated for IntelliJ Platform 2025.1
- 1.0: Initial release with syntax highlighting and ID generation
@@ -53,6 +61,13 @@
language="Capnp"
implementationClass="com.sercapnp.lang.CapnpFoldingBuilder"/>
+
+
+
@@ -64,6 +79,20 @@
+
+
+
+
+
+
+
+