Files
upstream-mirror 0f4df23290
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
snapshot of https://github.com/sqlc-dev/sqlc.git (main, 2026-09-16; blobs > 131072B omitted)
2026-09-16 02:17:34 +05:00

482 lines
16 KiB
Go

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
}