Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25d40ad515 | |||
| bac966c2ac | |||
| 4abc89700f | |||
| ad52f21cc2 | |||
| 60602d1de7 | |||
| 8d19258aa0 | |||
| 5cba6beeb1 | |||
| a0838e4bdc | |||
| 44fb77efd6 | |||
| 4fab2fe652 |
@@ -365,6 +365,12 @@ jobs:
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Set version from tag
|
||||
working-directory: editors/vscode
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
npm pkg set version="${TAG#v}"
|
||||
|
||||
- name: Install and package
|
||||
working-directory: editors/vscode
|
||||
run: |
|
||||
@@ -402,6 +408,12 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
|
||||
- name: Set version from tag
|
||||
working-directory: editors/datagrip
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
sed -i "s/^pluginVersion=.*/pluginVersion=${TAG#v}/" gradle.properties
|
||||
|
||||
- name: Build plugin
|
||||
working-directory: editors/datagrip
|
||||
run: ./gradlew buildPlugin
|
||||
|
||||
@@ -44,6 +44,10 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: editors/vscode/package-lock.json
|
||||
|
||||
- name: Set version from tag
|
||||
working-directory: editors/vscode
|
||||
run: npm pkg set version="${GITHUB_REF_NAME#v}"
|
||||
|
||||
- name: Install and package
|
||||
working-directory: editors/vscode
|
||||
run: |
|
||||
@@ -73,6 +77,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Set version from tag
|
||||
working-directory: editors/datagrip
|
||||
run: sed -i "s/^pluginVersion=.*/pluginVersion=${GITHUB_REF_NAME#v}/" gradle.properties
|
||||
|
||||
- name: Build plugin
|
||||
working-directory: editors/datagrip
|
||||
run: ./gradlew buildPlugin
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -59,6 +59,14 @@ testdata/corpus/ — real-world .pgsql procedures used as the safety/idempoten
|
||||
3. **Idempotence**: `fmt(fmt(x)) == fmt(x)`.
|
||||
4. **Graceful degradation**: any span the parser cannot handle is passed through verbatim
|
||||
rather than corrupted.
|
||||
5. **Runtime safety gate**: every frontend (CLI `fmt`, LSP `textDocument/formatting` and
|
||||
`rangeFormatting`) calls `format.SemanticallyEqual(src, out)` before writing or returning
|
||||
formatted output. It re-lexes both sides and compares non-trivia token streams
|
||||
(case-insensitive for identifiers/keywords, exact otherwise, recursing into dollar-quoted
|
||||
bodies). If it ever returns false, the formatter has a bug — the caller must refuse to
|
||||
write/emit the result and keep the original source, never guess or best-effort it. This is
|
||||
not just a test assertion (`pkg/format/format_test.go`); it is enforced at runtime so a
|
||||
formatter bug can never silently drop or alter code.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -111,8 +119,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
|
||||
|
||||
@@ -63,6 +63,10 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
return 2
|
||||
}
|
||||
out := format.File(parser.Parse(string(src)), st)
|
||||
if !format.SemanticallyEqual(string(src), out) {
|
||||
_, _ = fmt.Fprintln(stderr, "pgtidy: refusing to format stdin: formatter safety check failed (output would change code content)")
|
||||
return 2
|
||||
}
|
||||
switch {
|
||||
case check:
|
||||
if out != string(src) {
|
||||
@@ -86,6 +90,11 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
continue
|
||||
}
|
||||
out := format.File(parser.Parse(string(src)), st)
|
||||
if !format.SemanticallyEqual(string(src), out) {
|
||||
_, _ = fmt.Fprintf(stderr, "pgtidy: refusing to format %s: formatter safety check failed (output would change code content)\n", path)
|
||||
exit = 2
|
||||
continue
|
||||
}
|
||||
changed := out != string(src)
|
||||
if changed {
|
||||
anyDiff = true
|
||||
|
||||
+27
-5
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -20,6 +21,7 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
var (
|
||||
only []string // --only=ID,ID rule filter
|
||||
fix bool
|
||||
jsonOutput bool
|
||||
files []string
|
||||
)
|
||||
for _, a := range args {
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,4 +5,3 @@ pluginSinceBuild=243
|
||||
# DataGrip 2024.3
|
||||
platformVersion=2024.3
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.pgtidy.datagrip
|
||||
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.progress.ProgressIndicator
|
||||
import com.intellij.openapi.progress.Task
|
||||
import com.intellij.openapi.ui.Messages
|
||||
|
||||
class PgTidyShowVersionAction : AnAction() {
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project
|
||||
|
||||
object : Task.Backgroundable(project, "PgTidy: checking version…", false) {
|
||||
override fun run(indicator: ProgressIndicator) {
|
||||
val proc = try {
|
||||
ProcessBuilder("pgtidy", "version")
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
} catch (ex: Exception) {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
Messages.showErrorDialog(project, "Cannot start pgtidy: ${ex.message}", "PgTidy")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val output = proc.inputStream.bufferedReader().readText()
|
||||
val stderr = proc.errorStream.bufferedReader().readText()
|
||||
val exit = proc.waitFor()
|
||||
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (exit != 0) {
|
||||
Messages.showErrorDialog(project, stderr.ifBlank { "pgtidy exited with code $exit" }, "PgTidy")
|
||||
return@invokeLater
|
||||
}
|
||||
Messages.showInfoMessage(project, output.trim(), "PgTidy Version")
|
||||
}
|
||||
}
|
||||
}.queue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.pgtidy.datagrip
|
||||
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.wm.StatusBar
|
||||
import com.intellij.openapi.wm.StatusBarWidget
|
||||
import com.intellij.openapi.wm.StatusBarWidgetFactory
|
||||
import com.intellij.util.Consumer
|
||||
import java.awt.event.MouseEvent
|
||||
|
||||
private const val WIDGET_ID = "com.pgtidy.StatusBarWidget"
|
||||
|
||||
class PgTidyStatusBarWidget(private val project: Project) : StatusBarWidget, StatusBarWidget.TextPresentation {
|
||||
|
||||
private var statusBar: StatusBar? = null
|
||||
private var text: String = "pgtidy: …"
|
||||
|
||||
override fun ID(): String = WIDGET_ID
|
||||
|
||||
override fun install(statusBar: StatusBar) {
|
||||
this.statusBar = statusBar
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
statusBar = null
|
||||
}
|
||||
|
||||
override fun getPresentation(): StatusBarWidget.WidgetPresentation = this
|
||||
|
||||
override fun getText(): String = text
|
||||
|
||||
override fun getAlignment(): Float = java.awt.Component.CENTER_ALIGNMENT
|
||||
|
||||
override fun getTooltipText(): String = "PgTidy version — click to refresh"
|
||||
|
||||
override fun getClickConsumer(): Consumer<MouseEvent> = Consumer { refresh() }
|
||||
|
||||
fun refresh() {
|
||||
ApplicationManager.getApplication().executeOnPooledThread {
|
||||
text = try {
|
||||
val proc = ProcessBuilder("pgtidy", "version")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = proc.inputStream.bufferedReader().readText().trim()
|
||||
val exit = proc.waitFor()
|
||||
if (exit == 0 && output.isNotBlank()) output else "pgtidy: not found"
|
||||
} catch (ex: Exception) {
|
||||
"pgtidy: not found"
|
||||
}
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
statusBar?.updateWidget(WIDGET_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PgTidyStatusBarWidgetFactory : StatusBarWidgetFactory {
|
||||
override fun getId(): String = WIDGET_ID
|
||||
|
||||
override fun getDisplayName(): String = "PgTidy Version"
|
||||
|
||||
override fun isAvailable(project: Project): Boolean = true
|
||||
|
||||
override fun createWidget(project: Project): StatusBarWidget = PgTidyStatusBarWidget(project)
|
||||
|
||||
override fun disposeWidget(widget: StatusBarWidget) {
|
||||
widget.dispose()
|
||||
}
|
||||
|
||||
override fun canBeEnabledOn(statusBar: StatusBar): Boolean = true
|
||||
}
|
||||
@@ -5,13 +5,11 @@
|
||||
<vendor url="https://git.warky.dev/wdevs/PgTidy">Warky Devs</vendor>
|
||||
<description><![CDATA[
|
||||
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
|
||||
<a href="https://plugins.jetbrains.com/plugin/23257-lsp4ij">LSP4IJ</a> plugin.
|
||||
Requires the <code>pgtidy</code> binary on PATH.
|
||||
]]></description>
|
||||
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<depends>com.intellij.database</depends>
|
||||
<depends>com.redhat.devtools.lsp4ij</depends>
|
||||
|
||||
<actions>
|
||||
<action id="com.pgtidy.FormatDocument"
|
||||
@@ -21,17 +19,19 @@
|
||||
<add-to-group group-id="EditorPopupMenu" anchor="first"/>
|
||||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl alt shift P"/>
|
||||
</action>
|
||||
<action id="com.pgtidy.ShowVersion"
|
||||
class="com.pgtidy.datagrip.PgTidyShowVersionAction"
|
||||
text="Show PgTidy Version"
|
||||
description="Show the installed pgtidy binary version">
|
||||
<add-to-group group-id="ToolsMenu" anchor="last"/>
|
||||
</action>
|
||||
</actions>
|
||||
|
||||
<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 defaultExtensionNs="com.intellij">
|
||||
<statusBarWidgetFactory id="com.pgtidy.StatusBarWidget"
|
||||
implementation="com.pgtidy.datagrip.PgTidyStatusBarWidgetFactory"
|
||||
order="last"/>
|
||||
<externalAnnotator language="" implementationClass="com.pgtidy.datagrip.PgTidyExternalAnnotator"/>
|
||||
</extensions>
|
||||
|
||||
</idea-plugin>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||
pkgname=pgtidy-bin
|
||||
pkgver=0.0.3
|
||||
pkgver=0.0.7
|
||||
pkgrel=1
|
||||
pkgdesc="PostgreSQL SQL formatter and linter"
|
||||
arch=('x86_64' 'aarch64')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: pgtidy
|
||||
Version: 0.0.3
|
||||
Version: 0.0.7
|
||||
Release: 1%{?dist}
|
||||
Summary: PostgreSQL SQL formatter and linter
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
+153
-8
@@ -379,6 +379,7 @@ func formatBodyStatements(text string, st config.Style) string {
|
||||
stmt []bline
|
||||
parenDepth int
|
||||
blockDepth int // 0=col-0 (BEGIN/END/EXCEPTION), 1=body, 2=nested…
|
||||
caseDepth int // depth of open CASE…END expressions (WHEN…THEN is not a block opener)
|
||||
inException bool
|
||||
pendingBlanks int
|
||||
depthInc bool // increment blockDepth after next flush
|
||||
@@ -440,13 +441,9 @@ func formatBodyStatements(text string, st config.Style) string {
|
||||
stmtLines = joinThenToCondition(stmt)
|
||||
}
|
||||
|
||||
for i, ll := range stmtLines {
|
||||
if i == 0 || ll.indent == "" {
|
||||
result.WriteString(baseIndent)
|
||||
} else {
|
||||
result.WriteString(ll.indent)
|
||||
}
|
||||
result.WriteString(ll.text)
|
||||
formattedLines := formatBodyStmtLines(stmtLines, baseIndent, st)
|
||||
for _, line := range formattedLines {
|
||||
result.WriteString(line)
|
||||
result.WriteString(nl)
|
||||
}
|
||||
|
||||
@@ -529,18 +526,41 @@ func formatBodyStatements(text string, st config.Style) string {
|
||||
}
|
||||
if parenDepth == 0 && tok.Kind == lexer.Ident {
|
||||
lastD0Kw = lowerASCII(tok.Text)
|
||||
switch lastD0Kw {
|
||||
case "case":
|
||||
caseDepth++
|
||||
case "end":
|
||||
if caseDepth > 0 {
|
||||
caseDepth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if parenDepth == 0 && len(stmt) > 0 {
|
||||
switch lastD0Kw {
|
||||
case "then", "loop", "begin":
|
||||
case "then":
|
||||
// A THEN ending a CASE…WHEN branch is not a PL/pgSQL block
|
||||
// opener; only one matching END closes the whole CASE, so
|
||||
// treating each WHEN…THEN as a block open would permanently
|
||||
// inflate blockDepth.
|
||||
if caseDepth == 0 {
|
||||
fw0 := lowerASCII(firstBodyKeyword(stmt[0].text))
|
||||
if fw0 != "elsif" && fw0 != "elseif" {
|
||||
depthInc = true
|
||||
}
|
||||
flush()
|
||||
}
|
||||
case "loop", "begin":
|
||||
fw0 := lowerASCII(firstBodyKeyword(stmt[0].text))
|
||||
if fw0 != "elsif" && fw0 != "elseif" {
|
||||
depthInc = true
|
||||
}
|
||||
flush()
|
||||
case "else", "exception":
|
||||
if caseDepth > 0 {
|
||||
break
|
||||
}
|
||||
flush()
|
||||
}
|
||||
}
|
||||
@@ -550,6 +570,131 @@ func formatBodyStatements(text string, st config.Style) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// formatBodyStmtLines formats one flushed PL/pgSQL statement at its contextual
|
||||
// base indent. Multi-line UPDATE/DELETE statements inside PL/pgSQL get their
|
||||
// top-level SET/WHERE/AND/OR clauses realigned under the statement while nested
|
||||
// subqueries keep their original indentation. Non-DML statements keep
|
||||
// continuation indentation, except that standalone structural keywords such as
|
||||
// THEN are aligned with the block opener.
|
||||
func formatBodyStmtLines(lines []bline, baseIndent string, st config.Style) []string {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if looksLikeMultiLineBodyDML(lines) {
|
||||
return reindentBodyDML(lines, baseIndent, st)
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(lines))
|
||||
for i, ll := range lines {
|
||||
text := ll.text
|
||||
indent := baseIndent
|
||||
if i > 0 && ll.indent != "" && !isStandaloneBodyKeyword(ll.text, "then", "else", "elsif", "elseif") {
|
||||
indent = ll.indent
|
||||
}
|
||||
out = append(out, indent+text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func looksLikeMultiLineBodyDML(lines []bline) bool {
|
||||
if len(lines) < 2 {
|
||||
return false
|
||||
}
|
||||
kw := lowerASCII(firstBodyKeyword(lines[0].text))
|
||||
return kw == "update" || kw == "delete"
|
||||
}
|
||||
|
||||
func reindentBodyDML(lines []bline, baseIndent string, st config.Style) []string {
|
||||
out := make([]string, 0, len(lines)+1)
|
||||
afterWhere := false
|
||||
parenDepth := 0
|
||||
for i, ll := range lines {
|
||||
text := strings.TrimRight(ll.text, " ")
|
||||
lineDepth := parenDepth
|
||||
kw := lowerASCII(firstBodyKeyword(text))
|
||||
if afterWhere && lineDepth == 0 && kw != "and" && kw != "or" {
|
||||
out = append(out, baseIndent+st.Indent+st.Indent+strings.TrimSpace(text))
|
||||
afterWhere = false
|
||||
updateBodyParenDepth(text, &parenDepth)
|
||||
continue
|
||||
}
|
||||
if lineDepth == 0 && (kw == "set" || kw == "where" || kw == "values" || kw == "returning") {
|
||||
if kw == "where" {
|
||||
whereText := strings.TrimSpace(text)
|
||||
fields := strings.Fields(whereText)
|
||||
nextKw := ""
|
||||
if i+1 < len(lines) {
|
||||
nextKw = lowerASCII(firstBodyKeyword(lines[i+1].text))
|
||||
}
|
||||
if len(fields) > 1 && (nextKw == "and" || nextKw == "or") {
|
||||
out = append(out, baseIndent+fields[0])
|
||||
out = append(out, baseIndent+st.Indent+st.Indent+strings.TrimSpace(whereText[len(fields[0]):]))
|
||||
afterWhere = false
|
||||
continue
|
||||
}
|
||||
afterWhere = len(fields) == 1
|
||||
}
|
||||
out = append(out, baseIndent+strings.TrimSpace(text))
|
||||
continue
|
||||
}
|
||||
if lineDepth == 0 && (kw == "and" || kw == "or") {
|
||||
out = append(out, baseIndent+st.Indent+strings.TrimSpace(text))
|
||||
afterWhere = false
|
||||
updateBodyParenDepth(text, &parenDepth)
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
out = append(out, baseIndent+strings.TrimSpace(text))
|
||||
} else if ll.indent != "" {
|
||||
out = append(out, ll.indent+strings.TrimSpace(text))
|
||||
} else {
|
||||
out = append(out, baseIndent+strings.TrimSpace(text))
|
||||
}
|
||||
afterWhere = false
|
||||
updateBodyParenDepth(text, &parenDepth)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func updateBodyParenDepth(s string, depth *int) {
|
||||
for _, tok := range lexer.Lex(s) {
|
||||
switch tok.Kind {
|
||||
case lexer.LParen, lexer.LBracket:
|
||||
(*depth)++
|
||||
case lexer.RParen, lexer.RBracket:
|
||||
if *depth > 0 {
|
||||
(*depth)--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func significantBodyTokens(s string) []cst.Tok {
|
||||
var toks []cst.Tok
|
||||
for _, tok := range lexer.Lex(s) {
|
||||
if tok.IsTrivia() || tok.Kind == lexer.EOF {
|
||||
continue
|
||||
}
|
||||
toks = append(toks, cst.Tok{Tok: tok})
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
func isStandaloneBodyKeyword(s string, kws ...string) bool {
|
||||
toks := significantBodyTokens(s)
|
||||
if len(toks) != 1 || toks[0].Tok.Kind != lexer.Ident {
|
||||
return false
|
||||
}
|
||||
low := lowerASCII(toks[0].Tok.Text)
|
||||
for _, kw := range kws {
|
||||
if low == kw {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// joinThenToCondition merges a THEN line (on its own bline) into the preceding
|
||||
// condition line when plpgsql_if_then_newline is false.
|
||||
func joinThenToCondition(lines []bline) []bline {
|
||||
|
||||
+62
-44
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/config"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/parser"
|
||||
)
|
||||
|
||||
@@ -107,6 +106,66 @@ func TestFormatMmProcBroken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatIssue1PLpgSQLIndenting(t *testing.T) {
|
||||
src := "CREATE FUNCTION f() RETURNS void LANGUAGE plpgsql AS $$\n" +
|
||||
"DECLARE\n" +
|
||||
" r_lp record;\n" +
|
||||
"BEGIN\n" +
|
||||
" if r_lp.total > 0\n" +
|
||||
" and r_lp.totaldone >= r_lp.total\n" +
|
||||
" then\n" +
|
||||
" update core.process u\n" +
|
||||
" set status = 'done'\n" +
|
||||
" where u.rid_process = r_lp.rid_process\n" +
|
||||
" and nv(u.status) <> 'done';\n" +
|
||||
" elsif r_lp.total > 0\n" +
|
||||
" then\n" +
|
||||
" update core.process u\n" +
|
||||
" set status = 'open'\n" +
|
||||
" where u.rid_process = r_lp.rid_process\n" +
|
||||
" and nv(u.status) <> 'open';\n" +
|
||||
"\n" +
|
||||
" end if;\n" +
|
||||
"$$;\n"
|
||||
|
||||
want := "CREATE FUNCTION f(\n" +
|
||||
")\n" +
|
||||
"RETURNS void\n" +
|
||||
"LANGUAGE plpgsql\n" +
|
||||
"AS\n" +
|
||||
"$$\n" +
|
||||
"DECLARE\n" +
|
||||
" r_lp record;\n" +
|
||||
"BEGIN\n" +
|
||||
" if r_lp.total > 0\n" +
|
||||
" and r_lp.totaldone >= r_lp.total\n" +
|
||||
" then\n" +
|
||||
" update core.process u\n" +
|
||||
" set status = 'done'\n" +
|
||||
" where\n" +
|
||||
" u.rid_process = r_lp.rid_process\n" +
|
||||
" and nv(u.status) <> 'done';\n" +
|
||||
" elsif r_lp.total > 0\n" +
|
||||
" then\n" +
|
||||
" update core.process u\n" +
|
||||
" set status = 'open'\n" +
|
||||
" where\n" +
|
||||
" u.rid_process = r_lp.rid_process\n" +
|
||||
" and nv(u.status) <> 'open';\n" +
|
||||
"\n" +
|
||||
" end if;\n" +
|
||||
"$$;\n"
|
||||
|
||||
got := format(src)
|
||||
if got != want {
|
||||
t.Errorf("issue #1 PL/pgSQL indenting\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||
}
|
||||
checkDML(t, "issue #1 PL/pgSQL indenting", got)
|
||||
if !semanticallyEqual(src, got) {
|
||||
t.Errorf("issue #1 PL/pgSQL indenting changed semantics")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorpusIdempotentAndSafe(t *testing.T) {
|
||||
dir := filepath.Join("..", "..", "testdata", "corpus")
|
||||
entries, err := os.ReadDir(dir)
|
||||
@@ -139,48 +198,7 @@ func TestCorpusIdempotentAndSafe(t *testing.T) {
|
||||
t.Logf("formatted %d corpus files (idempotent + semantically equal)", seen)
|
||||
}
|
||||
|
||||
// semanticallyEqual compares the non-trivia token streams of two sources,
|
||||
// treating unquoted identifiers/keywords case-insensitively and everything
|
||||
// else (strings, numbers, operators, punctuation) exactly. Dollar-quoted body
|
||||
// tokens are compared recursively so body whitespace normalization does not
|
||||
// trigger a false failure.
|
||||
// semanticallyEqual is a test-local alias for the exported safety check.
|
||||
func semanticallyEqual(a, b string) bool {
|
||||
ta := significant(a)
|
||||
tb := significant(b)
|
||||
if len(ta) != len(tb) {
|
||||
return false
|
||||
}
|
||||
for i := range ta {
|
||||
if ta[i].Kind != tb[i].Kind {
|
||||
return false
|
||||
}
|
||||
switch ta[i].Kind {
|
||||
case lexer.Ident:
|
||||
if !strings.EqualFold(ta[i].Text, tb[i].Text) {
|
||||
return false
|
||||
}
|
||||
case lexer.DollarString:
|
||||
_, innerA, _, okA := splitDollarQuote(ta[i].Text)
|
||||
_, innerB, _, okB := splitDollarQuote(tb[i].Text)
|
||||
if okA != okB || (okA && !semanticallyEqual(innerA, innerB)) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if ta[i].Text != tb[i].Text {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func significant(src string) []lexer.Token {
|
||||
var out []lexer.Token
|
||||
for _, t := range lexer.Lex(src) {
|
||||
if t.Kind == lexer.EOF || t.IsTrivia() {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
return SemanticallyEqual(a, b)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package format
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
|
||||
)
|
||||
|
||||
// SemanticallyEqual reports whether a and b have the same non-trivia token
|
||||
// stream, i.e. formatting may only ever change whitespace/comment trivia and
|
||||
// layout — it must never add, remove, or alter a token of actual code.
|
||||
// Unquoted identifiers/keywords compare case-insensitively (casing is a
|
||||
// style choice); everything else (strings, numbers, operators, punctuation)
|
||||
// must match exactly. Dollar-quoted body tokens are compared recursively so
|
||||
// that independent body reformatting doesn't trigger a false failure.
|
||||
//
|
||||
// The CLI and LSP must call this before ever writing or emitting formatted
|
||||
// output: if it returns false, the formatter has a bug and the original
|
||||
// source must be kept, never the (corrupting) formatted output.
|
||||
func SemanticallyEqual(a, b string) bool {
|
||||
ta := significantTokens(a)
|
||||
tb := significantTokens(b)
|
||||
if len(ta) != len(tb) {
|
||||
return false
|
||||
}
|
||||
for i := range ta {
|
||||
if ta[i].Kind != tb[i].Kind {
|
||||
return false
|
||||
}
|
||||
switch ta[i].Kind {
|
||||
case lexer.Ident:
|
||||
if !strings.EqualFold(ta[i].Text, tb[i].Text) {
|
||||
return false
|
||||
}
|
||||
case lexer.DollarString:
|
||||
_, innerA, _, okA := splitDollarQuote(ta[i].Text)
|
||||
_, innerB, _, okB := splitDollarQuote(tb[i].Text)
|
||||
if okA != okB || (okA && !SemanticallyEqual(innerA, innerB)) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if ta[i].Text != tb[i].Text {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// significantTokens lexes src and returns its tokens excluding EOF and trivia
|
||||
// (whitespace/comments).
|
||||
func significantTokens(src string) []lexer.Token {
|
||||
var out []lexer.Token
|
||||
for _, t := range lexer.Lex(src) {
|
||||
if t.Kind == lexer.EOF || t.IsTrivia() {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -124,6 +124,10 @@ func (s *server) handle(raw []byte) bool {
|
||||
s.reply(req.ID, []textEdit{})
|
||||
return false
|
||||
}
|
||||
if !format.SemanticallyEqual(text, formatted) {
|
||||
s.reply(req.ID, []textEdit{})
|
||||
return false
|
||||
}
|
||||
s.reply(req.ID, []textEdit{fullReplace(text, formatted)})
|
||||
case "textDocument/rangeFormatting":
|
||||
var p rangeFormattingParams
|
||||
@@ -224,6 +228,9 @@ func (s *server) rangeFormat(text string, r lspRange) []textEdit {
|
||||
if formatted == text {
|
||||
return nil
|
||||
}
|
||||
if !format.SemanticallyEqual(text, formatted) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split both versions into lines, keeping the trailing newline attached to
|
||||
// each element so that joining them reconstructs the original string.
|
||||
|
||||
Reference in New Issue
Block a user