From ad52f21cc2a7346c6e1636f45e8184aec5ba5b26 Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 17 Jul 2026 13:20:49 +0200 Subject: [PATCH] feat(datagrip): migrate from LSP4IJ to native CLI integration --- AGENTS.md | 7 +-- cmd/pgtidy/lint.go | 38 +++++++++++--- editors/datagrip/README.md | 24 +++------ editors/datagrip/build.gradle.kts | 1 - editors/datagrip/gradle.properties | 1 - .../com/pgtidy/datagrip/PgTidyDiagnostic.kt | 18 +++++++ .../datagrip/PgTidyExternalAnnotator.kt | 50 +++++++++++++++++++ .../com/pgtidy/datagrip/PgTidyLintRunner.kt | 32 ++++++++++++ .../com/pgtidy/datagrip/PgTidyOffsets.kt | 28 +++++++++++ .../com/pgtidy/datagrip/PgTidyQuickFix.kt | 25 ++++++++++ .../pgtidy/datagrip/PgTidyServerConnection.kt | 9 ---- .../pgtidy/datagrip/PgTidyServerFactory.kt | 10 ---- .../src/main/resources/META-INF/plugin.xml | 16 +----- pkg/diagnostics/diagnostics.go | 22 ++++---- 14 files changed, 207 insertions(+), 74 deletions(-) create mode 100644 editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyDiagnostic.kt create mode 100644 editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyExternalAnnotator.kt create mode 100644 editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyLintRunner.kt create mode 100644 editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyOffsets.kt create mode 100644 editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyQuickFix.kt delete mode 100644 editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyServerConnection.kt delete mode 100644 editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyServerFactory.kt diff --git a/AGENTS.md b/AGENTS.md index b836be9..f09bfda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ pkg/pgast/ — go-pgquery wrapper: SQL → real PG AST (lint, v2) pkg/lint/ — rule engine + rule packs (v2) pkg/lsp/ — LSP server (v3) editors/vscode/ — VSCode extension (v3) -editors/datagrip/ — LSP4IJ integration (v4) +editors/datagrip/ — native JetBrains plugin, shells out to the CLI (v4) testdata/corpus/ — real-world .pgsql procedures used as the safety/idempotence harness ``` @@ -111,8 +111,9 @@ go fmt ./... # Format Go code Per-platform VSIX (`win32/linux/darwin × x64/arm64`) built in CI matrix. ### V4 — DataGrip -- `editors/datagrip`: integrate via LSP4IJ (free, works across JetBrains editions incl. - DataGrip). No core changes expected. +- `editors/datagrip`: native JetBrains plugin (no LSP4IJ dependency) that shells out to the + `pgtidy` binary directly. Formatting and version info run `pgtidy fmt`/`version`; lint + diagnostics and quick-fixes run via a native `ExternalAnnotator` calling `pgtidy lint --json`. ## Verification - **Formatter:** `go test ./...` runs golden-file tests + corpus harness asserting idempotence diff --git a/cmd/pgtidy/lint.go b/cmd/pgtidy/lint.go index 1a12689..2ad5ec9 100644 --- a/cmd/pgtidy/lint.go +++ b/cmd/pgtidy/lint.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "io" "os" @@ -18,9 +19,10 @@ import ( // file:line:col: [RULEID] severity: message func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int { var ( - only []string // --only=ID,ID rule filter - fix bool - files []string + only []string // --only=ID,ID rule filter + fix bool + jsonOutput bool + files []string ) for _, a := range args { switch { @@ -29,6 +31,8 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 0 case a == "--fix": fix = true + case a == "--json": + jsonOutput = true case strings.HasPrefix(a, "--only="): ids := strings.Split(strings.TrimPrefix(a, "--only="), ",") for _, id := range ids { @@ -66,7 +70,13 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return diags, nil } - printDiags := func(diags []diagnostics.Diagnostic) { + var jsonDiags []diagnostics.Diagnostic + + reportDiags := func(diags []diagnostics.Diagnostic) { + if jsonOutput { + jsonDiags = append(jsonDiags, diags...) + return + } for _, d := range diags { loc := d.File if d.Line > 0 { @@ -92,14 +102,14 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int { _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err) return 2 } - if fix { + if fix && !jsonOutput { fixed := lint.ApplyFixes(string(src), diags) if fixed != string(src) { _, _ = io.WriteString(stdout, fixed) return 0 } } - printDiags(diags) + reportDiags(diags) if len(diags) > 0 { found = true } @@ -115,7 +125,7 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int { _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err) return 2 } - if fix { + if fix && !jsonOutput { fixed := lint.ApplyFixes(string(src), diags) if fixed != string(src) { if err := os.WriteFile(path, []byte(fixed), 0o644); err != nil { @@ -130,13 +140,24 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } } } - printDiags(diags) + reportDiags(diags) if len(diags) > 0 { found = true } } } + if jsonOutput { + if jsonDiags == nil { + jsonDiags = []diagnostics.Diagnostic{} + } + enc := json.NewEncoder(stdout) + if err := enc.Encode(jsonDiags); err != nil { + _, _ = fmt.Fprintf(stderr, "pgtidy: encoding json: %v\n", err) + return 2 + } + } + if found { return 1 } @@ -150,6 +171,7 @@ Read SQL from files (or stdin) and report lint findings. Flags: --fix Apply autofixes for fixable rules (MIG001, MIG003) and rewrite files + --json Emit findings as a JSON array instead of text (ignores --fix) --only=ID,... comma-separated rule IDs to enable (default: all rules) -h, --help show this help diff --git a/editors/datagrip/README.md b/editors/datagrip/README.md index 5aab621..39cd758 100644 --- a/editors/datagrip/README.md +++ b/editors/datagrip/README.md @@ -1,37 +1,27 @@ # PgTidy — DataGrip / JetBrains Plugin -Formats and lints SQL files in DataGrip (and any JetBrains IDE) via the LSP4IJ plugin. +Formats SQL files in DataGrip (and any JetBrains IDE) by shelling out to the `pgtidy` binary. ## Requirements - `pgtidy` binary on `PATH` — download from [releases](https://git.warky.dev/wdevs/PgTidy/releases) or build with `go install git.warky.dev/wdevs/pgtidy/cmd/pgtidy@latest` -- [LSP4IJ](https://plugins.jetbrains.com/plugin/23257-lsp4ij) plugin installed (free, by Red Hat) - DataGrip 2024.3+ (or any JetBrains IDE 2024.3+) ## Install the plugin -### Option A — Install from disk (`.zip`) - 1. Build: `./gradlew buildPlugin` (output in `build/distributions/`) 2. In DataGrip: **Settings → Plugins → ⚙ → Install Plugin from Disk…** → select the `.zip` 3. Restart the IDE -### Option B — Install LSP4IJ and configure manually (no plugin build needed) - -1. Install **LSP4IJ** from the marketplace (**Settings → Plugins → Marketplace → search "LSP4IJ"**) -2. Go to **Settings → Language Servers → + (Add)** -3. Fill in: - - **Name:** `PgTidy` - - **Command:** `pgtidy lsp` -4. Under **Mappings**, add file patterns: `*.sql`, `*.pgsql` -5. Click **OK** and restart the IDE - ## Features -- **Formatting** — `Code → Reformat Code` (`Ctrl+Alt+L`) formats the current SQL file -- **Diagnostics** — lint findings shown as inspections (MIG001–MIG003, COR001–003, NAM001–003) +- **Formatting** — `Format with PgTidy` (`Ctrl+Alt+Shift+P` or right-click in the editor) formats the current SQL file via `pgtidy fmt` +- **Version check** — `Tools → Show PgTidy Version` and the status bar widget show the installed `pgtidy` version +- **Diagnostics** — lint findings (MIG001–MIG003, COR001–003, NAM001–003) are shown inline as editor annotations, via a native `ExternalAnnotator` that runs `pgtidy lint --json` - **Quick fixes** — intention actions for MIG001 (add `CONCURRENTLY`) and MIG003 (add `NOT VALID`) ## Troubleshooting -**Server not starting:** Check **View → Tool Windows → LSP4IJ Consoles** for stderr output. Most common cause: `pgtidy` not found on `PATH` — update the command to the full path, e.g. `/usr/local/bin/pgtidy lsp`. +**"Cannot start pgtidy" error:** the `pgtidy` binary isn't on `PATH` for the IDE process. Install it or restart the IDE after adding it to `PATH`. + +**No diagnostics showing:** diagnostics only run on files with a `.sql` or `.pgsql` extension; check `pgtidy lint --json` runs cleanly on the file from a terminal with the same `PATH` as the IDE. diff --git a/editors/datagrip/build.gradle.kts b/editors/datagrip/build.gradle.kts index ed03e6e..ab63459 100644 --- a/editors/datagrip/build.gradle.kts +++ b/editors/datagrip/build.gradle.kts @@ -23,7 +23,6 @@ dependencies { val platformType = providers.gradleProperty("platformType") create(platformType, platformVersion) bundledPlugin("com.intellij.database") - plugin("com.redhat.devtools.lsp4ij:${providers.gradleProperty("lsp4ijVersion").get()}") } } diff --git a/editors/datagrip/gradle.properties b/editors/datagrip/gradle.properties index a30c0ce..3cbfc64 100644 --- a/editors/datagrip/gradle.properties +++ b/editors/datagrip/gradle.properties @@ -5,4 +5,3 @@ pluginSinceBuild=243 # DataGrip 2024.3 platformVersion=2024.3 platformType=DB -lsp4ijVersion=0.20.1 diff --git a/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyDiagnostic.kt b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyDiagnostic.kt new file mode 100644 index 0000000..762aa5a --- /dev/null +++ b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyDiagnostic.kt @@ -0,0 +1,18 @@ +package com.pgtidy.datagrip + +data class PgTidyFix( + val offset: Int, + val end: Int, + val new: String, + val title: String, +) + +data class PgTidyDiagnostic( + val ruleId: String, + val severity: String, + val message: String, + val file: String, + val line: Int, + val col: Int, + val fix: PgTidyFix?, +) diff --git a/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyExternalAnnotator.kt b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyExternalAnnotator.kt new file mode 100644 index 0000000..aa35a53 --- /dev/null +++ b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyExternalAnnotator.kt @@ -0,0 +1,50 @@ +package com.pgtidy.datagrip + +import com.intellij.lang.annotation.AnnotationHolder +import com.intellij.lang.annotation.ExternalAnnotator +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.editor.Document +import com.intellij.psi.PsiFile + +class PgTidyExternalAnnotator : ExternalAnnotator>() { + + override fun collectInformation(file: PsiFile): String? { + val ext = file.virtualFile?.extension?.lowercase() + if (ext != "sql" && ext != "pgsql") return null + return file.viewProvider.document?.text + } + + override fun doAnnotate(source: String): List = PgTidyLintRunner.run(source) + + override fun apply(file: PsiFile, diagnostics: List, holder: AnnotationHolder) { + val document = file.viewProvider.document ?: return + for (d in diagnostics) { + if (d.line <= 0 || d.line > document.lineCount) continue + val range = lineTailRange(document, d.line, d.col) ?: continue + + val severity = when (d.severity) { + "error" -> HighlightSeverity.ERROR + "warning" -> HighlightSeverity.WARNING + else -> HighlightSeverity.WEAK_WARNING + } + + val builder = holder.newAnnotation(severity, "[${d.ruleId}] ${d.message}").range(range) + if (d.fix != null) { + builder.withFix(PgTidyQuickFix(d.fix)) + } + builder.create() + } + } + + /** Byte column [col] (1-based) to end-of-line, converted to a document TextRange. */ + private fun lineTailRange(document: Document, line1Based: Int, col1Based: Int): com.intellij.openapi.util.TextRange? { + val lineIdx = line1Based - 1 + val lineStart = document.getLineStartOffset(lineIdx) + val lineEnd = document.getLineEndOffset(lineIdx) + val lineText = document.getText(com.intellij.openapi.util.TextRange(lineStart, lineEnd)) + val charCol = PgTidyOffsets.byteOffsetToCharIndex(lineText, col1Based - 1) + val start = lineStart + charCol + if (start >= lineEnd) return null + return com.intellij.openapi.util.TextRange(start, lineEnd) + } +} diff --git a/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyLintRunner.kt b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyLintRunner.kt new file mode 100644 index 0000000..3cd7138 --- /dev/null +++ b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyLintRunner.kt @@ -0,0 +1,32 @@ +package com.pgtidy.datagrip + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +object PgTidyLintRunner { + private val gson = Gson() + private val listType = TypeToken.getParameterized(List::class.java, PgTidyDiagnostic::class.java).type + + /** Runs `pgtidy lint --json` over [source]. Returns an empty list if pgtidy is missing or output is unparseable. */ + fun run(source: String): List { + val proc = try { + ProcessBuilder("pgtidy", "lint", "--json") + .redirectErrorStream(false) + .start() + } catch (ex: Exception) { + return emptyList() + } + + proc.outputStream.bufferedWriter().use { it.write(source) } + val output = proc.inputStream.bufferedReader().readText() + proc.errorStream.bufferedReader().readText() + proc.waitFor() + + if (output.isBlank()) return emptyList() + return try { + gson.fromJson>(output, listType) ?: emptyList() + } catch (ex: Exception) { + emptyList() + } + } +} diff --git a/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyOffsets.kt b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyOffsets.kt new file mode 100644 index 0000000..cd7bc3d --- /dev/null +++ b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyOffsets.kt @@ -0,0 +1,28 @@ +package com.pgtidy.datagrip + +/** + * pgtidy reports positions as UTF-8 byte offsets (Go strings are byte slices); + * IntelliJ documents index text as UTF-16 chars. Converts one to the other. + */ +object PgTidyOffsets { + fun byteOffsetToCharIndex(text: String, byteOffset: Int): Int { + var bytes = 0 + var i = 0 + while (i < text.length) { + val cp = text.codePointAt(i) + val charCount = Character.charCount(cp) + val byteLen = utf8Length(cp) + if (bytes + byteLen > byteOffset) return i + bytes += byteLen + i += charCount + } + return text.length + } + + private fun utf8Length(codePoint: Int): Int = when { + codePoint <= 0x7F -> 1 + codePoint <= 0x7FF -> 2 + codePoint <= 0xFFFF -> 3 + else -> 4 + } +} diff --git a/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyQuickFix.kt b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyQuickFix.kt new file mode 100644 index 0000000..48eae3e --- /dev/null +++ b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyQuickFix.kt @@ -0,0 +1,25 @@ +package com.pgtidy.datagrip + +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile +import com.intellij.util.IncorrectOperationException + +class PgTidyQuickFix(private val fix: PgTidyFix) : IntentionAction { + + override fun getText(): String = fix.title + override fun getFamilyName(): String = "PgTidy" + override fun startInWriteAction(): Boolean = true + + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = editor != null + + @Throws(IncorrectOperationException::class) + override fun invoke(project: Project, editor: Editor?, file: PsiFile?) { + val document = editor?.document ?: return + val text = document.text + val start = PgTidyOffsets.byteOffsetToCharIndex(text, fix.offset) + val end = PgTidyOffsets.byteOffsetToCharIndex(text, fix.end) + document.replaceString(start, end, fix.new) + } +} diff --git a/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyServerConnection.kt b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyServerConnection.kt deleted file mode 100644 index 7cf955b..0000000 --- a/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyServerConnection.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.pgtidy.datagrip - -import com.intellij.openapi.project.Project -import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider - -class PgTidyServerConnection(project: Project) : ProcessStreamConnectionProvider( - listOf("pgtidy", "lsp"), - project.basePath ?: System.getProperty("user.home"), -) diff --git a/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyServerFactory.kt b/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyServerFactory.kt deleted file mode 100644 index 5c1a202..0000000 --- a/editors/datagrip/src/main/kotlin/com/pgtidy/datagrip/PgTidyServerFactory.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.pgtidy.datagrip - -import com.intellij.openapi.project.Project -import com.redhat.devtools.lsp4ij.LanguageServerFactory -import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider - -class PgTidyServerFactory : LanguageServerFactory { - override fun createConnectionProvider(project: Project): StreamConnectionProvider = - PgTidyServerConnection(project) -} diff --git a/editors/datagrip/src/main/resources/META-INF/plugin.xml b/editors/datagrip/src/main/resources/META-INF/plugin.xml index 4c10102..30bc0d0 100644 --- a/editors/datagrip/src/main/resources/META-INF/plugin.xml +++ b/editors/datagrip/src/main/resources/META-INF/plugin.xml @@ -5,13 +5,11 @@ Warky Devs pgtidy.
- Requires the pgtidy binary on PATH and the - LSP4IJ plugin. + Requires the pgtidy binary on PATH. ]]>
com.intellij.modules.platform com.intellij.database - com.redhat.devtools.lsp4ij + - - - PostgreSQL formatter and linter (pgtidy lsp) - - - - diff --git a/pkg/diagnostics/diagnostics.go b/pkg/diagnostics/diagnostics.go index e46a5ed..9479b35 100644 --- a/pkg/diagnostics/diagnostics.go +++ b/pkg/diagnostics/diagnostics.go @@ -15,27 +15,27 @@ const ( // TextFix is a byte-range replacement that can be applied to the source SQL. // Replace src[Offset:End] with New. An insertion has Offset == End. type TextFix struct { - Offset int // byte offset in source (inclusive) - End int // byte offset in source (exclusive) - New string // replacement text - Title string // short description shown in editor UI + Offset int `json:"offset"` // byte offset in source (inclusive) + End int `json:"end"` // byte offset in source (exclusive) + New string `json:"new"` // replacement text + Title string `json:"title"` // short description shown in editor UI } // Diagnostic is a single lint finding. type Diagnostic struct { // RuleID is the stable identifier for the rule that produced this finding // (e.g. "MIG001"). - RuleID string + RuleID string `json:"ruleId"` // Severity is the urgency level. - Severity Severity + Severity Severity `json:"severity"` // Message is a human-readable description of the finding. - Message string + Message string `json:"message"` // File is the path to the source file, or "" for stdin. - File string + File string `json:"file"` // Line is the 1-based line number of the finding. - Line int + Line int `json:"line"` // Col is the 1-based column number of the finding. - Col int + Col int `json:"col"` // Fix is non-nil when an autofix is available for this diagnostic. - Fix *TextFix + Fix *TextFix `json:"fix,omitempty"` }