feat(datagrip): migrate from LSP4IJ to native CLI integration
CI / Test (push) Successful in 26s
CI / Build (push) Successful in 22s

This commit is contained in:
Hein
2026-07-17 13:20:49 +02:00
parent 60602d1de7
commit ad52f21cc2
14 changed files with 207 additions and 74 deletions
+4 -3
View File
@@ -36,7 +36,7 @@ pkg/pgast/ — go-pgquery wrapper: SQL → real PG AST (lint, v2)
pkg/lint/ — rule engine + rule packs (v2) pkg/lint/ — rule engine + rule packs (v2)
pkg/lsp/ — LSP server (v3) pkg/lsp/ — LSP server (v3)
editors/vscode/ — VSCode extension (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 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. Per-platform VSIX (`win32/linux/darwin × x64/arm64`) built in CI matrix.
### V4 — DataGrip ### V4 — DataGrip
- `editors/datagrip`: integrate via LSP4IJ (free, works across JetBrains editions incl. - `editors/datagrip`: native JetBrains plugin (no LSP4IJ dependency) that shells out to the
DataGrip). No core changes expected. `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 ## Verification
- **Formatter:** `go test ./...` runs golden-file tests + corpus harness asserting idempotence - **Formatter:** `go test ./...` runs golden-file tests + corpus harness asserting idempotence
+27 -5
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -20,6 +21,7 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var ( var (
only []string // --only=ID,ID rule filter only []string // --only=ID,ID rule filter
fix bool fix bool
jsonOutput bool
files []string files []string
) )
for _, a := range args { for _, a := range args {
@@ -29,6 +31,8 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0 return 0
case a == "--fix": case a == "--fix":
fix = true fix = true
case a == "--json":
jsonOutput = true
case strings.HasPrefix(a, "--only="): case strings.HasPrefix(a, "--only="):
ids := strings.Split(strings.TrimPrefix(a, "--only="), ",") ids := strings.Split(strings.TrimPrefix(a, "--only="), ",")
for _, id := range ids { for _, id := range ids {
@@ -66,7 +70,13 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return diags, nil 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 { for _, d := range diags {
loc := d.File loc := d.File
if d.Line > 0 { 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) _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
if fix { if fix && !jsonOutput {
fixed := lint.ApplyFixes(string(src), diags) fixed := lint.ApplyFixes(string(src), diags)
if fixed != string(src) { if fixed != string(src) {
_, _ = io.WriteString(stdout, fixed) _, _ = io.WriteString(stdout, fixed)
return 0 return 0
} }
} }
printDiags(diags) reportDiags(diags)
if len(diags) > 0 { if len(diags) > 0 {
found = true 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) _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
if fix { if fix && !jsonOutput {
fixed := lint.ApplyFixes(string(src), diags) fixed := lint.ApplyFixes(string(src), diags)
if fixed != string(src) { if fixed != string(src) {
if err := os.WriteFile(path, []byte(fixed), 0o644); err != nil { 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 { if len(diags) > 0 {
found = true 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 { if found {
return 1 return 1
} }
@@ -150,6 +171,7 @@ Read SQL from files (or stdin) and report lint findings.
Flags: Flags:
--fix Apply autofixes for fixable rules (MIG001, MIG003) and rewrite files --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) --only=ID,... comma-separated rule IDs to enable (default: all rules)
-h, --help show this help -h, --help show this help
+7 -17
View File
@@ -1,37 +1,27 @@
# PgTidy — DataGrip / JetBrains Plugin # 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 ## 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` - `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+) - DataGrip 2024.3+ (or any JetBrains IDE 2024.3+)
## Install the plugin ## Install the plugin
### Option A — Install from disk (`.zip`)
1. Build: `./gradlew buildPlugin` (output in `build/distributions/`) 1. Build: `./gradlew buildPlugin` (output in `build/distributions/`)
2. In DataGrip: **Settings → Plugins → ⚙ → Install Plugin from Disk…** → select the `.zip` 2. In DataGrip: **Settings → Plugins → ⚙ → Install Plugin from Disk…** → select the `.zip`
3. Restart the IDE 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 ## Features
- **Formatting** — `Code → Reformat Code` (`Ctrl+Alt+L`) formats the current SQL file - **Formatting** — `Format with PgTidy` (`Ctrl+Alt+Shift+P` or right-click in the editor) formats the current SQL file via `pgtidy fmt`
- **Diagnostics** — lint findings shown as inspections (MIG001MIG003, COR001003, NAM001003) - **Version check** — `Tools → Show PgTidy Version` and the status bar widget show the installed `pgtidy` version
- **Diagnostics** — lint findings (MIG001MIG003, COR001003, NAM001003) 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`) - **Quick fixes** — intention actions for MIG001 (add `CONCURRENTLY`) and MIG003 (add `NOT VALID`)
## Troubleshooting ## 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.
-1
View File
@@ -23,7 +23,6 @@ dependencies {
val platformType = providers.gradleProperty("platformType") val platformType = providers.gradleProperty("platformType")
create(platformType, platformVersion) create(platformType, platformVersion)
bundledPlugin("com.intellij.database") bundledPlugin("com.intellij.database")
plugin("com.redhat.devtools.lsp4ij:${providers.gradleProperty("lsp4ijVersion").get()}")
} }
} }
-1
View File
@@ -5,4 +5,3 @@ pluginSinceBuild=243
# DataGrip 2024.3 # DataGrip 2024.3
platformVersion=2024.3 platformVersion=2024.3
platformType=DB platformType=DB
lsp4ijVersion=0.20.1
@@ -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?,
)
@@ -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<String, List<PgTidyDiagnostic>>() {
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<PgTidyDiagnostic> = PgTidyLintRunner.run(source)
override fun apply(file: PsiFile, diagnostics: List<PgTidyDiagnostic>, 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)
}
}
@@ -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<PgTidyDiagnostic> {
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<List<PgTidyDiagnostic>>(output, listType) ?: emptyList()
} catch (ex: Exception) {
emptyList()
}
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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"),
)
@@ -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)
}
@@ -5,13 +5,11 @@
<vendor url="https://git.warky.dev/wdevs/PgTidy">Warky Devs</vendor> <vendor url="https://git.warky.dev/wdevs/PgTidy">Warky Devs</vendor>
<description><![CDATA[ <description><![CDATA[
PostgreSQL formatter and linter powered by <a href="https://github.com/hein/pgtidy">pgtidy</a>.<br/> PostgreSQL formatter and linter powered by <a href="https://github.com/hein/pgtidy">pgtidy</a>.<br/>
Requires the <code>pgtidy</code> binary on PATH and the Requires the <code>pgtidy</code> binary on PATH.
<a href="https://plugins.jetbrains.com/plugin/23257-lsp4ij">LSP4IJ</a> plugin.
]]></description> ]]></description>
<depends>com.intellij.modules.platform</depends> <depends>com.intellij.modules.platform</depends>
<depends>com.intellij.database</depends> <depends>com.intellij.database</depends>
<depends>com.redhat.devtools.lsp4ij</depends>
<actions> <actions>
<action id="com.pgtidy.FormatDocument" <action id="com.pgtidy.FormatDocument"
@@ -33,17 +31,7 @@
<statusBarWidgetFactory id="com.pgtidy.StatusBarWidget" <statusBarWidgetFactory id="com.pgtidy.StatusBarWidget"
implementation="com.pgtidy.datagrip.PgTidyStatusBarWidgetFactory" implementation="com.pgtidy.datagrip.PgTidyStatusBarWidgetFactory"
order="last"/> order="last"/>
<externalAnnotator language="" implementationClass="com.pgtidy.datagrip.PgTidyExternalAnnotator"/>
</extensions> </extensions>
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
<server id="com.pgtidy.lsp"
name="PgTidy"
factoryClass="com.pgtidy.datagrip.PgTidyServerFactory">
<description>PostgreSQL formatter and linter (pgtidy lsp)</description>
</server>
<fileNamePatternMapping patterns="*.sql;*.pgsql"
serverId="com.pgtidy.lsp"
languageId="SQL"/>
</extensions>
</idea-plugin> </idea-plugin>
+11 -11
View File
@@ -15,27 +15,27 @@ const (
// TextFix is a byte-range replacement that can be applied to the source SQL. // 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. // Replace src[Offset:End] with New. An insertion has Offset == End.
type TextFix struct { type TextFix struct {
Offset int // byte offset in source (inclusive) Offset int `json:"offset"` // byte offset in source (inclusive)
End int // byte offset in source (exclusive) End int `json:"end"` // byte offset in source (exclusive)
New string // replacement text New string `json:"new"` // replacement text
Title string // short description shown in editor UI Title string `json:"title"` // short description shown in editor UI
} }
// Diagnostic is a single lint finding. // Diagnostic is a single lint finding.
type Diagnostic struct { type Diagnostic struct {
// RuleID is the stable identifier for the rule that produced this finding // RuleID is the stable identifier for the rule that produced this finding
// (e.g. "MIG001"). // (e.g. "MIG001").
RuleID string RuleID string `json:"ruleId"`
// Severity is the urgency level. // Severity is the urgency level.
Severity Severity Severity Severity `json:"severity"`
// Message is a human-readable description of the finding. // 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 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 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 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 is non-nil when an autofix is available for this diagnostic.
Fix *TextFix Fix *TextFix `json:"fix,omitempty"`
} }