feat: add LSP server, VSCode + DataGrip extensions, release infra, autofix
- pkg/lsp: JSON-RPC 2.0 LSP server (formatting, diagnostics, codeAction quick-fixes) - cmd/pgtidy: lsp and config subcommands - pkg/diagnostics: TextFix struct for byte-range autofixes - pkg/lint: MIG001/MIG003 autofixes, ApplyFixes helper, --fix flag on lint command - editors/vscode: TypeScript extension with LanguageClient, showVersion/showConfig/formatDocument commands, logo - editors/datagrip: Gradle JetBrains plugin via LSP4IJ, pluginIcon - .goreleaser.yaml, .github/workflows: CI + release pipeline - Makefile: snapshot, release, vscode-compile, vscode-package targets - go.mod + all imports: module path updated to git.warky.dev/wdevs/pgtidy - assets: logo files (256px, 128px, 1024px, ico)
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package lint
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
|
||||
)
|
||||
|
||||
// ApplyFixes applies all autofixes from diags to src and returns the result.
|
||||
// Fixes are applied in reverse-offset order so earlier edits do not shift the
|
||||
// byte positions of later ones. Overlapping fixes are skipped.
|
||||
func ApplyFixes(src string, diags []diagnostics.Diagnostic) string {
|
||||
type fix struct {
|
||||
offset, end int
|
||||
new string
|
||||
}
|
||||
var fixes []fix
|
||||
for _, d := range diags {
|
||||
if d.Fix != nil {
|
||||
fixes = append(fixes, fix{d.Fix.Offset, d.Fix.End, d.Fix.New})
|
||||
}
|
||||
}
|
||||
if len(fixes) == 0 {
|
||||
return src
|
||||
}
|
||||
sort.Slice(fixes, func(i, j int) bool {
|
||||
return fixes[i].offset > fixes[j].offset
|
||||
})
|
||||
b := []byte(src)
|
||||
last := len(b) + 1 // sentinel: no fix applied yet
|
||||
for _, f := range fixes {
|
||||
if f.end > last {
|
||||
continue // overlaps a previously applied fix; skip
|
||||
}
|
||||
last = f.offset
|
||||
b = append(b[:f.offset], append([]byte(f.new), b[f.end:]...)...)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package lint_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/lint"
|
||||
)
|
||||
|
||||
func TestMIG001Fix(t *testing.T) {
|
||||
src := "CREATE INDEX idx_orders_user ON orders(user_id);"
|
||||
eng := lint.New()
|
||||
diags, _ := eng.Check(src, "test.sql")
|
||||
|
||||
var hasFix bool
|
||||
for _, d := range diags {
|
||||
if d.RuleID == "MIG001" && d.Fix != nil {
|
||||
hasFix = true
|
||||
}
|
||||
}
|
||||
if !hasFix {
|
||||
t.Fatal("MIG001 diagnostic missing Fix")
|
||||
}
|
||||
|
||||
fixed := lint.ApplyFixes(src, diags)
|
||||
if !strings.Contains(fixed, "CONCURRENTLY") {
|
||||
t.Errorf("fix did not insert CONCURRENTLY; got: %s", fixed)
|
||||
}
|
||||
// Re-check: MIG001 should be gone
|
||||
diags2, _ := eng.Check(fixed, "test.sql")
|
||||
for _, d := range diags2 {
|
||||
if d.RuleID == "MIG001" {
|
||||
t.Errorf("MIG001 still fires after fix: %s", fixed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMIG003Fix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
}{
|
||||
{
|
||||
"FK constraint",
|
||||
"ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id);",
|
||||
},
|
||||
{
|
||||
"CHECK constraint",
|
||||
"ALTER TABLE orders ADD CONSTRAINT chk_positive CHECK (amount > 0);",
|
||||
},
|
||||
}
|
||||
eng := lint.New()
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
diags, _ := eng.Check(tc.src, "test.sql")
|
||||
var hasFix bool
|
||||
for _, d := range diags {
|
||||
if d.RuleID == "MIG003" && d.Fix != nil {
|
||||
hasFix = true
|
||||
}
|
||||
}
|
||||
if !hasFix {
|
||||
t.Fatal("MIG003 diagnostic missing Fix")
|
||||
}
|
||||
fixed := lint.ApplyFixes(tc.src, diags)
|
||||
if !strings.Contains(fixed, "NOT VALID") {
|
||||
t.Errorf("fix did not insert NOT VALID; got: %s", fixed)
|
||||
}
|
||||
// Re-check: MIG003 should be gone
|
||||
diags2, _ := eng.Check(fixed, "test.sql")
|
||||
for _, d := range diags2 {
|
||||
if d.RuleID == "MIG003" {
|
||||
t.Errorf("MIG003 still fires after fix: %s", fixed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFixes_MultipleInOneFile(t *testing.T) {
|
||||
src := `CREATE INDEX a ON t(x);
|
||||
ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (x) REFERENCES u(id);`
|
||||
|
||||
eng := lint.New()
|
||||
diags, _ := eng.Check(src, "test.sql")
|
||||
fixed := lint.ApplyFixes(src, diags)
|
||||
|
||||
if !strings.Contains(fixed, "CONCURRENTLY") {
|
||||
t.Error("CONCURRENTLY missing after multi-fix")
|
||||
}
|
||||
if !strings.Contains(fixed, "NOT VALID") {
|
||||
t.Error("NOT VALID missing after multi-fix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFixes_NoFixes(t *testing.T) {
|
||||
src := "SELECT 1;"
|
||||
eng := lint.New()
|
||||
diags, _ := eng.Check(src, "test.sql")
|
||||
fixed := lint.ApplyFixes(src, diags)
|
||||
if fixed != src {
|
||||
t.Errorf("ApplyFixes changed unfixable source: %q", fixed)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
pg_query "github.com/pganalyze/pg_query_go/v6"
|
||||
|
||||
"github.com/hein/pgtidy/pkg/diagnostics"
|
||||
"github.com/hein/pgtidy/pkg/pgast"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/pgast"
|
||||
)
|
||||
|
||||
// Rule is implemented by each lint rule.
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hein/pgtidy/pkg/diagnostics"
|
||||
"github.com/hein/pgtidy/pkg/lint"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/lint"
|
||||
)
|
||||
|
||||
func fixtureDir() string {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
|
||||
pg_query "github.com/pganalyze/pg_query_go/v6"
|
||||
|
||||
"github.com/hein/pgtidy/pkg/diagnostics"
|
||||
"github.com/hein/pgtidy/pkg/pgast"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/pgast"
|
||||
)
|
||||
|
||||
// COR001 — SELECT *.
|
||||
|
||||
@@ -2,11 +2,12 @@ package lint
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
pg_query "github.com/pganalyze/pg_query_go/v6"
|
||||
|
||||
"github.com/hein/pgtidy/pkg/diagnostics"
|
||||
"github.com/hein/pgtidy/pkg/pgast"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/pgast"
|
||||
)
|
||||
|
||||
// MIG001 — CREATE INDEX without CONCURRENT.
|
||||
@@ -30,13 +31,17 @@ func (ruleMIG001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
|
||||
continue
|
||||
}
|
||||
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
|
||||
out = append(out, diagnostics.Diagnostic{
|
||||
d := diagnostics.Diagnostic{
|
||||
RuleID: "MIG001",
|
||||
Severity: diagnostics.SeverityWarning,
|
||||
Message: fmt.Sprintf("CREATE INDEX on %q without CONCURRENT blocks writes; use CREATE INDEX CONCURRENTLY", relName(s.Relation)),
|
||||
Line: line,
|
||||
Col: col,
|
||||
})
|
||||
}
|
||||
if fix := mig001Fix(src, int(raw.StmtLocation), int(raw.StmtLen)); fix != nil {
|
||||
d.Fix = fix
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -127,13 +132,17 @@ func (ruleMIG003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
|
||||
kind = "CHECK"
|
||||
}
|
||||
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
|
||||
out = append(out, diagnostics.Diagnostic{
|
||||
d := diagnostics.Diagnostic{
|
||||
RuleID: "MIG003",
|
||||
Severity: diagnostics.SeverityWarning,
|
||||
Message: fmt.Sprintf("ALTER TABLE %q ADD %s CONSTRAINT without NOT VALID validates all rows immediately; use NOT VALID + VALIDATE CONSTRAINT", relName(tbl.Relation), kind),
|
||||
Line: line,
|
||||
Col: col,
|
||||
})
|
||||
}
|
||||
if fix := mig003Fix(src, int(raw.StmtLocation), int(raw.StmtLen)); fix != nil {
|
||||
d.Fix = fix
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -169,3 +178,63 @@ func relName(rv *pg_query.RangeVar) string {
|
||||
}
|
||||
return rv.Relname
|
||||
}
|
||||
|
||||
// stmtText returns the text of a statement given its start offset and length.
|
||||
// When stmtLen is 0 (last statement in file) it extends to EOF.
|
||||
func stmtText(src string, stmtOffset, stmtLen int) string {
|
||||
end := stmtOffset + stmtLen
|
||||
if stmtLen == 0 || end > len(src) {
|
||||
end = len(src)
|
||||
}
|
||||
return src[stmtOffset:end]
|
||||
}
|
||||
|
||||
// mig001Fix builds the TextFix for MIG001: insert CONCURRENTLY after INDEX.
|
||||
func mig001Fix(src string, stmtOffset, stmtLen int) *diagnostics.TextFix {
|
||||
text := stmtText(src, stmtOffset, stmtLen)
|
||||
upper := strings.ToUpper(text)
|
||||
pos := strings.Index(upper, "INDEX")
|
||||
if pos < 0 {
|
||||
return nil
|
||||
}
|
||||
insertAt := stmtOffset + pos + len("INDEX")
|
||||
return &diagnostics.TextFix{
|
||||
Offset: insertAt,
|
||||
End: insertAt,
|
||||
New: " CONCURRENTLY",
|
||||
Title: "Add CONCURRENTLY",
|
||||
}
|
||||
}
|
||||
|
||||
// mig003Fix builds the TextFix for MIG003: insert NOT VALID before the trailing semicolon.
|
||||
// pg_query's StmtLen excludes the ";", which sits at src[stmtOffset+stmtLen].
|
||||
func mig003Fix(src string, stmtOffset, stmtLen int) *diagnostics.TextFix {
|
||||
end := stmtOffset + stmtLen
|
||||
semiAt := -1
|
||||
if stmtLen > 0 && end < len(src) && src[end] == ';' {
|
||||
semiAt = end
|
||||
} else {
|
||||
// Fallback for stmtLen=0 (last statement without terminator) or edge cases.
|
||||
if stmtLen == 0 {
|
||||
end = len(src)
|
||||
}
|
||||
idx := strings.LastIndex(src[stmtOffset:end], ";")
|
||||
if idx >= 0 {
|
||||
semiAt = stmtOffset + idx
|
||||
}
|
||||
}
|
||||
if semiAt < 0 {
|
||||
return nil
|
||||
}
|
||||
// Insert " NOT VALID" just before the ";", after any trailing whitespace.
|
||||
insertAt := semiAt
|
||||
for insertAt > stmtOffset && (src[insertAt-1] == ' ' || src[insertAt-1] == '\t' || src[insertAt-1] == '\n' || src[insertAt-1] == '\r') {
|
||||
insertAt--
|
||||
}
|
||||
return &diagnostics.TextFix{
|
||||
Offset: insertAt,
|
||||
End: insertAt,
|
||||
New: " NOT VALID",
|
||||
Title: "Add NOT VALID",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
pg_query "github.com/pganalyze/pg_query_go/v6"
|
||||
|
||||
"github.com/hein/pgtidy/pkg/diagnostics"
|
||||
"github.com/hein/pgtidy/pkg/pgast"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/pgast"
|
||||
)
|
||||
|
||||
// reSnakeCase matches valid snake_case identifiers: lowercase letters, digits,
|
||||
|
||||
Reference in New Issue
Block a user