Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25d40ad515 | ||
|
|
bac966c2ac |
@@ -365,6 +365,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: latest
|
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
|
- name: Install and package
|
||||||
working-directory: editors/vscode
|
working-directory: editors/vscode
|
||||||
run: |
|
run: |
|
||||||
@@ -402,6 +408,12 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '21'
|
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
|
- name: Build plugin
|
||||||
working-directory: editors/datagrip
|
working-directory: editors/datagrip
|
||||||
run: ./gradlew buildPlugin
|
run: ./gradlew buildPlugin
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: editors/vscode/package-lock.json
|
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
|
- name: Install and package
|
||||||
working-directory: editors/vscode
|
working-directory: editors/vscode
|
||||||
run: |
|
run: |
|
||||||
@@ -73,6 +77,10 @@ jobs:
|
|||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
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
|
- name: Build plugin
|
||||||
working-directory: editors/datagrip
|
working-directory: editors/datagrip
|
||||||
run: ./gradlew buildPlugin
|
run: ./gradlew buildPlugin
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ testdata/corpus/ — real-world .pgsql procedures used as the safety/idempoten
|
|||||||
3. **Idempotence**: `fmt(fmt(x)) == fmt(x)`.
|
3. **Idempotence**: `fmt(fmt(x)) == fmt(x)`.
|
||||||
4. **Graceful degradation**: any span the parser cannot handle is passed through verbatim
|
4. **Graceful degradation**: any span the parser cannot handle is passed through verbatim
|
||||||
rather than corrupted.
|
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
|
## Commands
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
out := format.File(parser.Parse(string(src)), st)
|
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 {
|
switch {
|
||||||
case check:
|
case check:
|
||||||
if out != string(src) {
|
if out != string(src) {
|
||||||
@@ -86,6 +90,11 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out := format.File(parser.Parse(string(src)), st)
|
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)
|
changed := out != string(src)
|
||||||
if changed {
|
if changed {
|
||||||
anyDiff = true
|
anyDiff = true
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||||
pkgname=pgtidy-bin
|
pkgname=pgtidy-bin
|
||||||
pkgver=0.0.6
|
pkgver=0.0.7
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="PostgreSQL SQL formatter and linter"
|
pkgdesc="PostgreSQL SQL formatter and linter"
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: pgtidy
|
Name: pgtidy
|
||||||
Version: 0.0.6
|
Version: 0.0.7
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: PostgreSQL SQL formatter and linter
|
Summary: PostgreSQL SQL formatter and linter
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/pgtidy/pkg/config"
|
"git.warky.dev/wdevs/pgtidy/pkg/config"
|
||||||
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
|
|
||||||
"git.warky.dev/wdevs/pgtidy/pkg/parser"
|
"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)
|
t.Logf("formatted %d corpus files (idempotent + semantically equal)", seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// semanticallyEqual compares the non-trivia token streams of two sources,
|
// semanticallyEqual is a test-local alias for the exported safety check.
|
||||||
// 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.
|
|
||||||
func semanticallyEqual(a, b string) bool {
|
func semanticallyEqual(a, b string) bool {
|
||||||
ta := significant(a)
|
return SemanticallyEqual(a, b)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{})
|
s.reply(req.ID, []textEdit{})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !format.SemanticallyEqual(text, formatted) {
|
||||||
|
s.reply(req.ID, []textEdit{})
|
||||||
|
return false
|
||||||
|
}
|
||||||
s.reply(req.ID, []textEdit{fullReplace(text, formatted)})
|
s.reply(req.ID, []textEdit{fullReplace(text, formatted)})
|
||||||
case "textDocument/rangeFormatting":
|
case "textDocument/rangeFormatting":
|
||||||
var p rangeFormattingParams
|
var p rangeFormattingParams
|
||||||
@@ -224,6 +228,9 @@ func (s *server) rangeFormat(text string, r lspRange) []textEdit {
|
|||||||
if formatted == text {
|
if formatted == text {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if !format.SemanticallyEqual(text, formatted) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Split both versions into lines, keeping the trailing newline attached to
|
// Split both versions into lines, keeping the trailing newline attached to
|
||||||
// each element so that joining them reconstructs the original string.
|
// each element so that joining them reconstructs the original string.
|
||||||
|
|||||||
Reference in New Issue
Block a user