snapshot of https://github.com/sqlc-dev/sqlc.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
kotlin / test (push) Has been skipped
python / test (push) Has been skipped
typescript / test (push) Has been skipped
go / build darwin/amd64 (push) Has been cancelled
go / build linux/amd64 (push) Has been cancelled
go / build windows/amd64 (push) Has been cancelled
go / build darwin/arm64 (push) Has been cancelled
go / build linux/arm64 (push) Has been cancelled
go / build windows/arm64 (push) Has been cancelled
go / test (push) Has been cancelled
go / vuln_check (push) Has been cancelled
Some checks failed
kotlin / test (push) Has been skipped
python / test (push) Has been skipped
typescript / test (push) Has been skipped
go / build darwin/amd64 (push) Has been cancelled
go / build linux/amd64 (push) Has been cancelled
go / build windows/amd64 (push) Has been cancelled
go / build darwin/arm64 (push) Has been cancelled
go / build linux/arm64 (push) Has been cancelled
go / build windows/arm64 (push) Has been cancelled
go / test (push) Has been cancelled
go / vuln_check (push) Has been cancelled
This commit is contained in:
481
internal/cmd/fmt.go
Normal file
481
internal/cmd/fmt.go
Normal file
@@ -0,0 +1,481 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/trace"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/cubicdaiya/gonp"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/config"
|
||||
"github.com/sqlc-dev/sqlc/internal/engine/dolphin"
|
||||
"github.com/sqlc-dev/sqlc/internal/engine/postgresql"
|
||||
"github.com/sqlc-dev/sqlc/internal/engine/sqlite"
|
||||
"github.com/sqlc-dev/sqlc/internal/sql/ast"
|
||||
"github.com/sqlc-dev/sqlc/internal/sql/format"
|
||||
"github.com/sqlc-dev/sqlc/internal/sql/sqlpath"
|
||||
)
|
||||
|
||||
// noLineLimit renders without a maximum line width: like gofmt, fmt never
|
||||
// rewraps a line on its own. Breaks come from the author's own line breaks
|
||||
// at the boundaries the printer models, and from comments, which cannot
|
||||
// share a line with the code after them.
|
||||
const noLineLimit = 1 << 30
|
||||
|
||||
// queryFormatter is what the fmt command needs from an engine: a parser
|
||||
// that surfaces the comments its lexer already scans (so statements and
|
||||
// comments come from one pass, and interior comments format along with
|
||||
// their statements) plus dialect-aware formatting. Comments cannot be
|
||||
// reprinted without parser support, so ParseFile is the price of admission.
|
||||
type queryFormatter interface {
|
||||
ParseFile(io.Reader) (*ast.File, error)
|
||||
format.Dialect
|
||||
}
|
||||
|
||||
// newQueryFormatter returns the formatter for engines fmt supports —
|
||||
// SQLite, PostgreSQL and MySQL today. An engine joins by teaching its
|
||||
// parser to surface comments (meyer's, oliphant's and marino's ParseFile
|
||||
// are the templates) and adding its case here.
|
||||
func newQueryFormatter(engine config.Engine) queryFormatter {
|
||||
switch engine {
|
||||
case config.EnginePostgreSQL:
|
||||
return postgresql.NewParser()
|
||||
case config.EngineSQLite:
|
||||
return sqlite.NewParser()
|
||||
case config.EngineMySQL:
|
||||
// The format parser preserves identifier case and proves every
|
||||
// statement via Fingerprint, the way oliphant does for PostgreSQL.
|
||||
return dolphin.NewFormatParser()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newFmtCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "fmt",
|
||||
Short: "Format SQL queries",
|
||||
Long: `Format the SQL query files referenced by the configuration file.
|
||||
|
||||
Each query is parsed with the engine's parser and printed back in a canonical
|
||||
form, with its comments kept where they were written. A statement that cannot
|
||||
be proven to survive formatting unchanged is left exactly as written. Files
|
||||
are rewritten in place; pass --diff to print the changes to stdout instead.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
defer trace.StartRegion(cmd.Context(), "fmt").End()
|
||||
stderr := cmd.ErrOrStderr()
|
||||
stdout := cmd.OutOrStdout()
|
||||
showDiff, err := cmd.Flags().GetBool("diff")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir, name := getConfigPath(stderr, cmd.Flag("file"))
|
||||
output, err := Format(cmd.Context(), dir, name, &Options{
|
||||
Env: ParseEnv(cmd),
|
||||
Stderr: stderr,
|
||||
})
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
filenames := make([]string, 0, len(output))
|
||||
for filename := range output {
|
||||
filenames = append(filenames, filename)
|
||||
}
|
||||
sort.Strings(filenames)
|
||||
|
||||
for _, filename := range filenames {
|
||||
existing, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "%s: %s\n", filename, err)
|
||||
return err
|
||||
}
|
||||
source := output[filename]
|
||||
if string(existing) == source {
|
||||
continue
|
||||
}
|
||||
if showDiff {
|
||||
diff := gonp.New(getLines(existing), getLines([]byte(source)))
|
||||
diff.Compose()
|
||||
uniHunks := filterHunks(diff.UnifiedHunks())
|
||||
if len(uniHunks) == 0 {
|
||||
continue
|
||||
}
|
||||
rel := filepath.ToSlash(strings.TrimPrefix(filename, dir))
|
||||
fmt.Fprintf(stdout, "--- a%s\n", rel)
|
||||
fmt.Fprintf(stdout, "+++ b%s\n", rel)
|
||||
diff.FprintUniHunks(stdout, uniHunks)
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(source), 0644); err != nil {
|
||||
fmt.Fprintf(stderr, "%s: %s\n", filename, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("diff", false, "display diffs instead of rewriting files")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Format formats every query file referenced by the configuration with its
|
||||
// engine's SQL formatter. It returns the formatted contents of each file,
|
||||
// keyed by absolute path. Query files for engines without a formatter are
|
||||
// skipped.
|
||||
func Format(ctx context.Context, dir, filename string, o *Options) (map[string]string, error) {
|
||||
defer trace.StartRegion(ctx, "format").End()
|
||||
stderr := o.Stderr
|
||||
|
||||
configPath, conf, err := o.ReadConfig(dir, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := filepath.Base(configPath)
|
||||
if err := config.Validate(conf); err != nil {
|
||||
fmt.Fprintf(stderr, "error validating %s: %s\n", base, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
engines := map[string]config.Engine{}
|
||||
var files []string
|
||||
for _, sql := range conf.SQL {
|
||||
joined := make([]string, 0, len(sql.Queries))
|
||||
for _, q := range sql.Queries {
|
||||
joined = append(joined, filepath.Join(dir, q))
|
||||
}
|
||||
list, err := sqlpath.Glob(joined)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error listing queries: %s\n", err)
|
||||
return nil, err
|
||||
}
|
||||
for _, file := range list {
|
||||
if _, ok := engines[file]; ok {
|
||||
continue
|
||||
}
|
||||
engines[file] = sql.Engine
|
||||
files = append(files, file)
|
||||
}
|
||||
}
|
||||
|
||||
output := map[string]string{}
|
||||
unsupported := map[config.Engine]bool{}
|
||||
for _, file := range files {
|
||||
f := newQueryFormatter(engines[file])
|
||||
if f == nil {
|
||||
if !unsupported[engines[file]] {
|
||||
unsupported[engines[file]] = true
|
||||
fmt.Fprintf(stderr, "sqlc fmt does not yet support the %s engine; query files left unchanged\n", engines[file])
|
||||
}
|
||||
continue
|
||||
}
|
||||
rel, err := filepath.Rel(dir, file)
|
||||
if err != nil {
|
||||
rel = file
|
||||
}
|
||||
contents, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "%s: skipped: %s\n", rel, err)
|
||||
continue
|
||||
}
|
||||
formatted, err := formatQueries(f, string(contents))
|
||||
if err != nil {
|
||||
// Not every query file parses without the compiler's help — for
|
||||
// example, some engines only accept sqlc.slice() after
|
||||
// preprocessing. Leave those files as they are.
|
||||
fmt.Fprintf(stderr, "%s: skipped: %s\n", rel, err)
|
||||
continue
|
||||
}
|
||||
// File-level belt for the reprinter path: no formatting result may
|
||||
// change the file's comments or its statement count. A violation is
|
||||
// a formatter bug; keep the file as written and say so.
|
||||
before, err1 := f.ParseFile(strings.NewReader(string(contents)))
|
||||
after, err2 := f.ParseFile(strings.NewReader(formatted))
|
||||
if err1 == nil && (err2 != nil || len(before.Stmts) != len(after.Stmts) || !sameComments(before.Comments, after.Comments)) {
|
||||
fmt.Fprintf(stderr, "%s: skipped: formatting would alter the file (this is a bug in sqlc fmt)\n", rel)
|
||||
continue
|
||||
}
|
||||
output[file] = formatted
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// formatQueries formats each statement in a query file. The comments above
|
||||
// every statement (that's where the "-- name:" annotation lives) are kept
|
||||
// as written; comments inside a statement are threaded through the printer
|
||||
// by position.
|
||||
func formatQueries(f queryFormatter, src string) (string, error) {
|
||||
// The parser hands over statements and comments from one lexer pass;
|
||||
// statement bodies slice the comment list by span.
|
||||
file, err := f.ParseFile(strings.NewReader(src))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stmts, fileComments := file.Stmts, file.Comments
|
||||
var blocks []string
|
||||
prevEnd := 0
|
||||
for _, stmt := range stmts {
|
||||
if stmt.Raw == nil {
|
||||
continue
|
||||
}
|
||||
start := stmt.Raw.StmtLocation
|
||||
length := stmt.Raw.StmtLen
|
||||
if length == 0 {
|
||||
// An unterminated final statement is reported with a zero length.
|
||||
length = len(src) - start
|
||||
}
|
||||
if start < 0 || start+length > len(src) {
|
||||
return "", fmt.Errorf("statement location out of bounds")
|
||||
}
|
||||
// Engines differ on whether a statement's span covers the comments
|
||||
// above it, so start each segment where the previous one ended: the
|
||||
// gap holds nothing but the previous terminator, whitespace and
|
||||
// comments.
|
||||
segStart := prevEnd
|
||||
if start < segStart {
|
||||
segStart = start
|
||||
}
|
||||
if start+length <= prevEnd {
|
||||
continue
|
||||
}
|
||||
prevEnd = start + length
|
||||
seg := src[segStart : start+length]
|
||||
segOff := segStart
|
||||
// A comment on the same line as the previous statement's terminator
|
||||
// belongs to that statement, not to this one's header.
|
||||
if len(blocks) > 0 {
|
||||
if trailing, remainder := splitTrailingComment(seg); trailing != "" {
|
||||
blocks[len(blocks)-1] += " " + trailing
|
||||
segOff += len(seg) - len(remainder)
|
||||
seg = remainder
|
||||
}
|
||||
}
|
||||
orig := strings.TrimSpace(strings.TrimLeft(seg, "; \t\r\n"))
|
||||
if orig == "" {
|
||||
continue
|
||||
}
|
||||
header, rest := splitLeadingComments(orig)
|
||||
// The statement body's own file offset, so its interior comments
|
||||
// can be sliced out of the file's comment list in the same
|
||||
// coordinates the parser stamped on the nodes.
|
||||
restOff := segOff + strings.Index(seg, orig) + len(orig) - len(rest)
|
||||
var interior []ast.Comment
|
||||
for _, c := range fileComments {
|
||||
if c.Start >= restOff && c.End <= start+length {
|
||||
interior = append(interior, c)
|
||||
}
|
||||
}
|
||||
block := append([]string{}, header...)
|
||||
block = append(block, formatStmt(f, stmt.Raw, rest, interior, src))
|
||||
blocks = append(blocks, strings.Join(block, "\n"))
|
||||
}
|
||||
// Everything after the last statement is its terminator, whitespace and
|
||||
// trailing comments; keep the comments.
|
||||
tailSeg := src[prevEnd:]
|
||||
if len(blocks) > 0 {
|
||||
if trailing, remainder := splitTrailingComment(tailSeg); trailing != "" {
|
||||
blocks[len(blocks)-1] += " " + trailing
|
||||
tailSeg = remainder
|
||||
}
|
||||
}
|
||||
if tail := strings.TrimSpace(strings.TrimLeft(tailSeg, "; \t\r\n")); tail != "" {
|
||||
blocks = append(blocks, tail)
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return src, nil
|
||||
}
|
||||
return strings.Join(blocks, "\n\n") + "\n", nil
|
||||
}
|
||||
|
||||
// splitTrailingComment splits a comment sitting on the same line as the
|
||||
// previous statement's terminator off the front of the text between two
|
||||
// statements, returning the comment (or "") and the remaining text.
|
||||
func splitTrailingComment(seg string) (string, string) {
|
||||
k := 0
|
||||
for k < len(seg) && (seg[k] == ';' || seg[k] == ' ' || seg[k] == '\t' || seg[k] == '\r') {
|
||||
k++
|
||||
}
|
||||
rest := seg[k:]
|
||||
line, _, _ := strings.Cut(rest, "\n")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "--"), strings.HasPrefix(trimmed, "#"):
|
||||
return trimmed, seg[k+len(line):]
|
||||
case strings.HasPrefix(trimmed, "/*") &&
|
||||
strings.HasSuffix(trimmed, "*/") && strings.Count(trimmed, "*/") == 1:
|
||||
// A block comment contained on the terminator's line.
|
||||
return trimmed, seg[k+len(line):]
|
||||
}
|
||||
return "", seg
|
||||
}
|
||||
|
||||
// splitLeadingComments splits a statement's text into the comment lines above
|
||||
// it and the statement itself. Comment lines are returned with surrounding
|
||||
// whitespace trimmed (block comment continuation lines keep their leading
|
||||
// whitespace); blank lines between comments are kept.
|
||||
func splitLeadingComments(text string) ([]string, string) {
|
||||
var header []string
|
||||
rest := text
|
||||
for rest != "" {
|
||||
line, remainder, found := strings.Cut(rest, "\n")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "/*") && !strings.Contains(trimmed, "*/") {
|
||||
// A block comment spanning several lines: commit it to the
|
||||
// header only when its closing line arrives with nothing after
|
||||
// it. Otherwise leave the whole block with the statement.
|
||||
block := []string{trimmed}
|
||||
blockRest := remainder
|
||||
for closed := false; !closed; {
|
||||
if !found || blockRest == "" {
|
||||
return header, rest
|
||||
}
|
||||
var blockLine string
|
||||
blockLine, blockRest, found = strings.Cut(blockRest, "\n")
|
||||
if end := strings.TrimSpace(blockLine); strings.Contains(end, "*/") {
|
||||
if !strings.HasSuffix(end, "*/") || strings.Count(end, "*/") != 1 {
|
||||
return header, rest
|
||||
}
|
||||
closed = true
|
||||
}
|
||||
block = append(block, strings.TrimRight(blockLine, " \t\r"))
|
||||
}
|
||||
header = append(header, block...)
|
||||
rest = blockRest
|
||||
continue
|
||||
}
|
||||
if !isCommentLine(trimmed) {
|
||||
break
|
||||
}
|
||||
header = append(header, trimmed)
|
||||
if !found {
|
||||
rest = ""
|
||||
break
|
||||
}
|
||||
rest = remainder
|
||||
}
|
||||
// A statement never ends with a comment-only line here (the parser found a
|
||||
// statement in this segment), but drop any blank lines left at the end of
|
||||
// the header so comments sit directly above their query.
|
||||
for len(header) > 0 && header[len(header)-1] == "" {
|
||||
header = header[:len(header)-1]
|
||||
}
|
||||
return header, rest
|
||||
}
|
||||
|
||||
func isCommentLine(line string) bool {
|
||||
switch {
|
||||
case line == "":
|
||||
// A blank line between comments.
|
||||
return true
|
||||
case strings.HasPrefix(line, "--"):
|
||||
return true
|
||||
case strings.HasPrefix(line, "#"):
|
||||
// MySQL's line-comment syntax; text that reaches these helpers sits
|
||||
// outside statements, where a # line in a file the engine parsed
|
||||
// can only be a comment.
|
||||
return true
|
||||
case strings.HasPrefix(line, "/*") && strings.HasSuffix(line, "*/") && strings.Count(line, "*/") == 1:
|
||||
// A block comment contained on a single line.
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// fingerprinter is implemented by engines that can reduce a query to a
|
||||
// fingerprint that survives changes in whitespace, case and layout —
|
||||
// PostgreSQL via oliphant's pg_query-compatible Fingerprint. Where it is
|
||||
// available, fmt gets a proof the other checks cannot give: the formatted
|
||||
// statement still parses to the same query as the original.
|
||||
type fingerprinter interface {
|
||||
Fingerprint(string) (string, error)
|
||||
}
|
||||
|
||||
// formatStmt returns the canonical form of a single statement, or the
|
||||
// original text when formatting cannot be proven to preserve the query.
|
||||
func formatStmt(f queryFormatter, raw *ast.RawStmt, orig string, interior []ast.Comment, src string) string {
|
||||
fallback := strings.TrimSuffix(strings.TrimSpace(orig), ";") + ";"
|
||||
if out, ok := formatWithComments(f, raw, interior, src); ok {
|
||||
if fp, ok := f.(fingerprinter); ok && !sameFingerprint(fp, orig, out) {
|
||||
return fallback
|
||||
}
|
||||
return out
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// sameFingerprint reports that both texts fingerprint successfully to the
|
||||
// same value. Anything less is not a proof, so the caller keeps the
|
||||
// statement as written.
|
||||
func sameFingerprint(fp fingerprinter, orig, out string) bool {
|
||||
a, err1 := fp.Fingerprint(orig)
|
||||
b, err2 := fp.Fingerprint(out)
|
||||
return err1 == nil && err2 == nil && a == b
|
||||
}
|
||||
|
||||
// formatWithComments pretty-prints a statement with its interior comments
|
||||
// and proves the result faithful three ways before accepting it: every
|
||||
// comment survives (multiset equality after reparsing the output), the SQL
|
||||
// still parses as one statement, and the output is a fixed point (printing
|
||||
// the reparsed statement with its reparsed comments reproduces it).
|
||||
func formatWithComments(f queryFormatter, raw *ast.RawStmt, interior []ast.Comment, src string) (string, bool) {
|
||||
out := prettyCommented(raw, f, interior, src)
|
||||
if strings.TrimSpace(strings.TrimSuffix(out, ";")) == "" {
|
||||
return "", false
|
||||
}
|
||||
file, err := f.ParseFile(strings.NewReader(out))
|
||||
if err != nil || len(file.Stmts) != 1 || file.Stmts[0].Raw == nil {
|
||||
return "", false
|
||||
}
|
||||
if !sameComments(interior, file.Comments) {
|
||||
return "", false
|
||||
}
|
||||
again := prettyCommented(file.Stmts[0].Raw, f, file.Comments, out)
|
||||
if !strings.EqualFold(again, out) {
|
||||
return "", false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// prettyCommented attaches a statement's comments to its nodes and renders
|
||||
// it, returning "" when the printer panics or fails to place every comment.
|
||||
func prettyCommented(raw *ast.RawStmt, f queryFormatter, comments []ast.Comment, src string) (out string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
out = ""
|
||||
}
|
||||
}()
|
||||
ct := ast.AttachComments(raw, f, comments, src)
|
||||
out = ast.PrettyWithComments(raw, f, noLineLimit, ct)
|
||||
if !ct.Exhausted() {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameComments reports whether two comment lists carry the same comment
|
||||
// texts, as multisets, ignoring surrounding whitespace.
|
||||
func sameComments(a, b []ast.Comment) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
counts := make(map[string]int, len(a))
|
||||
for _, c := range a {
|
||||
counts[strings.TrimSpace(c.Text)]++
|
||||
}
|
||||
for _, c := range b {
|
||||
counts[strings.TrimSpace(c.Text)]--
|
||||
}
|
||||
for _, n := range counts {
|
||||
if n != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user