4 Commits
Author SHA1 Message Date
Hein 25d40ad515 chore(release): bump version to 0.0.7
CI / Build (push) Successful in 20s
Release / Release (push) Successful in 36s
Release / Debian packages (push) Successful in 33s
Release / VSCode Extension (push) Successful in 35s
Release / AUR package (push) Successful in 55s
Release / RPM package (push) Successful in 56s
Release / Windows installer (push) Successful in 59s
Release / DataGrip Plugin (push) Successful in 4m29s
CI / Test (push) Successful in 33s
Release / Test (push) Successful in 31s
2026-07-17 14:29:45 +02:00
Hein bac966c2ac feat(format): add runtime semantic-equality safety gate
CI / Build (push) Successful in 23s
CI / Test (push) Successful in 31s
2026-07-17 14:29:24 +02:00
Hein 4abc89700f chore(release): bump version to 0.0.6
Release / AUR package (push) Successful in 53s
Release / DataGrip Plugin (push) Successful in 4m17s
Release / Release (push) Successful in 1m57s
Release / VSCode Extension (push) Successful in 56s
Release / Test (push) Successful in 1m50s
CI / Test (push) Successful in 1m51s
CI / Build (push) Successful in 21s
Release / RPM package (push) Successful in 39s
Release / Debian packages (push) Successful in 47s
Release / Windows installer (push) Successful in 47s
2026-07-17 13:21:20 +02:00
Hein ad52f21cc2 feat(datagrip): migrate from LSP4IJ to native CLI integration
CI / Test (push) Successful in 26s
CI / Build (push) Successful in 22s
2026-07-17 13:20:49 +02:00
22 changed files with 316 additions and 120 deletions
+12
View File
@@ -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
+8
View File
@@ -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
+12 -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/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
+9
View File
@@ -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
+30 -8
View File
@@ -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
+7 -17
View File
@@ -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 (MIG001MIG003, COR001003, NAM001003)
- **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 (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`)
## 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")
create(platformType, platformVersion)
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
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)
}
@@ -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"
@@ -33,17 +31,7 @@
<statusBarWidgetFactory id="com.pgtidy.StatusBarWidget"
implementation="com.pgtidy.datagrip.PgTidyStatusBarWidgetFactory"
order="last"/>
<externalAnnotator language="" implementationClass="com.pgtidy.datagrip.PgTidyExternalAnnotator"/>
</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>
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=pgtidy-bin
pkgver=0.0.5
pkgver=0.0.7
pkgrel=1
pkgdesc="PostgreSQL SQL formatter and linter"
arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: pgtidy
Version: 0.0.5
Version: 0.0.7
Release: 1%{?dist}
Summary: PostgreSQL SQL formatter and linter
+11 -11
View File
@@ -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"`
}
+2 -44
View File
@@ -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"
)
@@ -199,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)
}
+61
View File
@@ -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
}
+7
View File
@@ -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.