From 2f553a4cfd7b93b24c836f5b9e488f37b1fdc735 Mon Sep 17 00:00:00 2001 From: Ahmet Refik Gunes Date: Sat, 4 Apr 2026 11:54:26 +0100 Subject: [PATCH 1/6] V4 --- .../lang/CapnpCompletionContributor.java | 344 ++++++++++++++ .../lang/CapnpGotoDeclarationHandler.java | 223 +++++++++ .../lang/CapnpOrdinalTypedHandler.java | 299 ++++++++++++ .../com/sercapnp/lang/CapnpSchemaScanner.java | 436 ++++++++++++++++++ src/main/resources/META-INF/plugin.xml | 14 +- 5 files changed, 1315 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sercapnp/lang/CapnpCompletionContributor.java create mode 100644 src/main/java/com/sercapnp/lang/CapnpGotoDeclarationHandler.java create mode 100644 src/main/java/com/sercapnp/lang/CapnpOrdinalTypedHandler.java create mode 100644 src/main/java/com/sercapnp/lang/CapnpSchemaScanner.java 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..c7fdafb 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 + 5.0 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
  • +
  • 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 +24,8 @@ +
  • 5.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 +58,13 @@ language="Capnp" implementationClass="com.sercapnp.lang.CapnpFoldingBuilder"/> + + + From c6300135759a3f156169b1e0cb4a27619f0cb381 Mon Sep 17 00:00:00 2001 From: Ahmet Refik Gunes Date: Tue, 14 Apr 2026 22:06:27 +0100 Subject: [PATCH 2/6] Wrong version number Updated version number from 5.0 to 4.0 and modified change notes. --- src/main/resources/META-INF/plugin.xml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index c7fdafb..e38103e 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,7 +1,7 @@ com.sercapnp SerCapnp - 5.0 + 4.0 xmonader -
  • 5.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
  • +
  • 4.0: Code completion — built-in types, keywords, user-defined types from current file and imports, annotation names/targets, constants, import paths. Go to definition (Ctrl+Click cross-file navigation); ordinal auto-increment (@N auto-numbering)
  • 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
  • From 414fe8f5061b5a04fbb36a739c24178db2d75adb Mon Sep 17 00:00:00 2001 From: Ahmet Refik Gunes Date: Sat, 16 May 2026 15:01:58 +0100 Subject: [PATCH 3/6] V4.1 --- .../sercapnp/actions/ShiftOrdinalsAction.java | 412 ++++++++++++++++++ .../actions/UnshiftOrdinalsAction.java | 304 +++++++++++++ src/main/resources/META-INF/plugin.xml | 22 +- 3 files changed, 736 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/sercapnp/actions/ShiftOrdinalsAction.java create mode 100644 src/main/java/com/sercapnp/actions/UnshiftOrdinalsAction.java 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/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index e38103e..28f3383 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,7 +1,7 @@ com.sercapnp SerCapnp - 4.0 + 4.1 xmonader 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
    • @@ -24,7 +26,9 @@ -
    • 4.0: Code completion — built-in types, keywords, user-defined types from current file and imports, annotation names/targets, constants, import paths. Go to definition (Ctrl+Click cross-file navigation); ordinal auto-increment (@N auto-numbering)
    • +
    • 6.0: Shift ordinals down/up — insert or remove fields in the middle with automatic renumbering (Ctrl+Alt+A, S / U)
    • +
    • 5.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
    • @@ -75,6 +79,20 @@ + + + + + + + + From b4cae7c013185bb689fb7b71b7e889b8ceeccae5 Mon Sep 17 00:00:00 2001 From: Ahmet Refik Gunes Date: Sat, 16 May 2026 15:03:41 +0100 Subject: [PATCH 4/6] V4.1 --- src/main/resources/META-INF/plugin.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 28f3383..84af8d0 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,7 +1,7 @@ com.sercapnp SerCapnp - 4.1 + 4.2 xmonader -
    • 6.0: Shift ordinals down/up — insert or remove fields in the middle with automatic renumbering (Ctrl+Alt+A, S / U)
    • -
    • 5.0: Go to definition (Ctrl+Click cross-file navigation); ordinal auto-increment (@N auto-numbering)
    • +
    • 4.2: Shift ordinals down/up — insert or remove fields in the middle with automatic renumbering (Ctrl+Alt+A, S / U)
    • +
    • 4.1: 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
    • From 2d89c9dbaf91dbc4a504293efbbdb75513754564 Mon Sep 17 00:00:00 2001 From: Ahmet Refik Gunes Date: Sat, 16 May 2026 15:04:28 +0100 Subject: [PATCH 5/6] V4.1 --- src/main/resources/META-INF/plugin.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 84af8d0..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 - 4.2 + 4.1 xmonader -
    • 4.2: Shift ordinals down/up — insert or remove fields in the middle with automatic renumbering (Ctrl+Alt+A, S / U)
    • -
    • 4.1: Go to definition (Ctrl+Click cross-file navigation); ordinal auto-increment (@N auto-numbering)
    • +
    • 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
    • From c585bf7fac45ed2a5c0f0d11a2bd5e3e14ec136f Mon Sep 17 00:00:00 2001 From: Ahmet Refik Gunes Date: Sat, 16 May 2026 15:07:54 +0100 Subject: [PATCH 6/6] V4.1 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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