diff --git a/formatter/bench_test.go b/formatter/bench_test.go new file mode 100644 index 000000000..a87b522d4 --- /dev/null +++ b/formatter/bench_test.go @@ -0,0 +1,87 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package formatter_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/onflow/cadence/formatter" +) + +func loadSnapshotInputs(b *testing.B) map[string][]byte { + b.Helper() + root := findRepoRoot(b) + dir := filepath.Join(root, "testdata", "format") + entries, err := os.ReadDir(dir) + if err != nil { + b.Fatalf("reading testdata dir: %v", err) + } + inputs := make(map[string][]byte, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name(), "input.cdc")) + if err != nil { + b.Fatalf("reading input %s: %v", e.Name(), err) + } + inputs[e.Name()] = data + } + return inputs +} + +// --- End-to-end benchmarks --- + +func BenchmarkFormat_Snapshot(b *testing.B) { + inputs := loadSnapshotInputs(b) + opts := formatter.Default() + + var totalBytes int64 + for _, data := range inputs { + totalBytes += int64(len(data)) + } + + b.ResetTimer() + b.SetBytes(totalBytes) + for b.Loop() { + for name, data := range inputs { + if _, err := formatter.Format(data, name+".cdc", opts); err != nil { + b.Fatalf("format %s: %v", name, err) + } + } + } +} + +func BenchmarkFormat_PerCase(b *testing.B) { + inputs := loadSnapshotInputs(b) + opts := formatter.Default() + + for name, data := range inputs { + b.Run(name, func(b *testing.B) { + b.SetBytes(int64(len(data))) + for b.Loop() { + if _, err := formatter.Format(data, name+".cdc", opts); err != nil { + b.Fatalf("format: %v", err) + } + } + }) + } +} diff --git a/formatter/formatter.go b/formatter/formatter.go new file mode 100644 index 000000000..a0a7e9352 --- /dev/null +++ b/formatter/formatter.go @@ -0,0 +1,221 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package formatter + +import ( + "bytes" + "errors" + "fmt" + "strings" + + "github.com/turbolent/prettier" + + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/formatter/render" + "github.com/onflow/cadence/formatter/rewrite" + "github.com/onflow/cadence/formatter/trivia" + "github.com/onflow/cadence/formatter/verify" + "github.com/onflow/cadence/parser" +) + +// ErrParse marks errors caused by malformed input source. +// ErrInternal marks errors caused by formatter bugs (rewrite failure, +// orphaned comments, round-trip verification failure). Callers can +// distinguish these via errors.Is to choose an appropriate exit code. +var ( + ErrParse = errors.New("parse error") + ErrInternal = errors.New("internal error") +) + +// Format parses Cadence source and returns deterministically formatted output. +// filename is used for diagnostics only; the file need not exist on disk. +func Format(src []byte, filename string, opts Options) ([]byte, error) { + if err := opts.Validate(); err != nil { + return nil, err + } + + program, err := parser.ParseProgram(nil, src, parser.Config{}) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrParse, err) + } + + // Extract and attach comments + comments := trivia.Scan(src) + groups := trivia.Group(comments, src) + cm := trivia.Attach(program, groups, src) + + // Apply AST rewrites (import sorting, etc.) + if err := rewrite.Apply(program, cm, opts.SortImports); err != nil { + return nil, fmt.Errorf("%w: rewrite failed: %w", ErrInternal, err) + } + + indent := strings.Repeat(opts.IndentCharacter, opts.IndentCount) + + // Render AST with interleaved comments + var semicolons map[ast.Element]bool + if !opts.StripSemicolons { + semicolons = trivia.ScanSemicolons(src, program) + } + doc := render.Program(program, cm, src, semicolons) + + var buf bytes.Buffer + prettier.Prettier(&buf, doc, opts.LineWidth, indent) + + result := collapseBlankLines( + rejoinStringInterpolations(stripTrailingLineWhitespace(buf.Bytes())), + opts.KeepBlankLines, + ) + + // Verify no orphaned comments remain + if !cm.IsEmpty() { + details := cm.OrphanDetails() + return result, fmt.Errorf("%w: orphaned comments remain in CommentMap\n%s", ErrInternal, details) + } + + // Round-trip verification: re-parse and compare ASTs + if !opts.SkipVerify { + if err := verify.RoundTrip(src, result); err != nil { + return result, fmt.Errorf("%w: round-trip verification failed: %w", ErrInternal, err) + } + } + + return result, nil +} + +// rejoinStringInterpolations collapses line breaks inside string template +// interpolations \(...). The prettier library may break expressions inside +// interpolations across lines; this rejoins them into a single line. +// Tracks paren depth to find the matching ) for each \(. +func rejoinStringInterpolations(data []byte) []byte { + result := make([]byte, 0, len(data)) + i := 0 + inString := false + + for i < len(data) { + b := data[i] + + // Track string boundaries (handle escaped quotes) + if b == '"' && !inString { + inString = true + result = append(result, b) + i++ + continue + } + if b == '"' && inString { + inString = false + result = append(result, b) + i++ + continue + } + + // Handle escape sequences inside strings + if inString && b == '\\' && i+1 < len(data) { + if data[i+1] == '(' { + // Start of interpolation \( — scan to matching ) + result = append(result, '\\', '(') + i += 2 + depth := 1 + for i < len(data) && depth > 0 { + c := data[i] + if c == '(' { + depth++ + result = append(result, c) + } else if c == ')' { + depth-- + result = append(result, c) + } else if c == '\n' { + // Collapse newline + following whitespace. The expression + // content already has operators/dots that provide spacing. + i++ + for i < len(data) && (data[i] == ' ' || data[i] == '\t') { + i++ + } + // Add a space unless the next char is . (member access) + if i < len(data) && data[i] != '.' { + result = append(result, ' ') + } + continue + } else if c == '"' { + // Nested string inside interpolation — copy until closing " + result = append(result, c) + i++ + for i < len(data) && data[i] != '"' { + if data[i] == '\\' && i+1 < len(data) { + result = append(result, data[i], data[i+1]) + i += 2 + continue + } + result = append(result, data[i]) + i++ + } + if i < len(data) { + result = append(result, data[i]) // closing " + } + } else { + result = append(result, c) + } + i++ + } + continue + } + // Other escape: copy both bytes + result = append(result, b, data[i+1]) + i += 2 + continue + } + + result = append(result, b) + i++ + } + + return result +} + +// collapseBlankLines limits consecutive blank lines to at most max. +func collapseBlankLines(data []byte, max int) []byte { + lines := bytes.Split(data, []byte("\n")) + result := make([][]byte, 0, len(lines)) + consecutive := 0 + for _, line := range lines { + if len(bytes.TrimSpace(line)) == 0 { + consecutive++ + if consecutive > max { + continue + } + } else { + consecutive = 0 + } + result = append(result, line) + } + return bytes.Join(result, []byte("\n")) +} + +// stripTrailingLineWhitespace strips indent whitespace from blank lines. +// The prettier library emits indent prefixes on blank lines inside Indent +// blocks (e.g. " \n" instead of "\n"); this cleans that up. +// Only whitespace-only lines are affected — content lines are not touched. +func stripTrailingLineWhitespace(data []byte) []byte { + lines := bytes.Split(data, []byte("\n")) + for i, line := range lines { + if len(bytes.TrimRight(line, " \t")) == 0 { + lines[i] = nil + } + } + return bytes.Join(lines, []byte("\n")) +} diff --git a/formatter/formatter_test.go b/formatter/formatter_test.go new file mode 100644 index 000000000..07971d06a --- /dev/null +++ b/formatter/formatter_test.go @@ -0,0 +1,590 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package formatter_test + +import ( + "flag" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/onflow/cadence/formatter" + "github.com/onflow/cadence/formatter/trivia" + "github.com/onflow/cadence/formatter/verify" +) + +const testdataRoot = "testdata" + +var update = flag.Bool("update", false, "update golden files") + +func TestSnapshot(t *testing.T) { + t.Parallel() + + testdataDir := filepath.Join(testdataRoot, "format") + + entries, err := os.ReadDir(testdataDir) + if err != nil { + t.Fatalf("reading testdata dir: %v", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + t.Run(name, func(t *testing.T) { + t.Parallel() + + dir := filepath.Join(testdataDir, name) + inputPath := filepath.Join(dir, "input.cdc") + goldenPath := filepath.Join(dir, "golden.cdc") + + input, err := os.ReadFile(inputPath) + if err != nil { + t.Fatalf("reading input: %v", err) + } + + got, err := formatter.Format(input, inputPath, formatter.Default()) + if err != nil { + t.Fatalf("format error: %v", err) + } + + if *update { + if err := os.WriteFile(goldenPath, got, 0644); err != nil { + t.Fatalf("writing golden: %v", err) + } + return + } + + golden, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("reading golden (run with -update to create): %v", err) + } + + if string(got) != string(golden) { + t.Errorf("output does not match golden.\n--- got ---\n%s\n--- golden ---\n%s", + string(got), string(golden)) + } + }) + } +} + +func TestIdempotence(t *testing.T) { + t.Parallel() + + testdataDir := filepath.Join(testdataRoot, "format") + + entries, err := os.ReadDir(testdataDir) + if err != nil { + t.Fatalf("reading testdata dir: %v", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + t.Run(name, func(t *testing.T) { + t.Parallel() + + dir := filepath.Join(testdataDir, name) + inputPath := filepath.Join(dir, "input.cdc") + + input, err := os.ReadFile(inputPath) + if err != nil { + t.Fatalf("reading input: %v", err) + } + + first, err := formatter.Format(input, inputPath, formatter.Default()) + if err != nil { + t.Fatalf("first format: %v", err) + } + + second, err := formatter.Format(first, inputPath, formatter.Default()) + if err != nil { + t.Fatalf("second format: %v", err) + } + + if string(first) != string(second) { + t.Errorf("not idempotent.\n--- first ---\n%s\n--- second ---\n%s", + string(first), string(second)) + } + }) + } +} + +func TestRoundTrip(t *testing.T) { + t.Parallel() + + testdataDir := filepath.Join(testdataRoot, "format") + + entries, err := os.ReadDir(testdataDir) + if err != nil { + t.Fatalf("reading testdata dir: %v", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := filepath.Join(testdataDir, name) + inputPath := filepath.Join(dir, "input.cdc") + + input, err := os.ReadFile(inputPath) + if err != nil { + t.Fatalf("reading input: %v", err) + } + + output, err := formatter.Format(input, inputPath, formatter.Default()) + if err != nil { + t.Fatalf("format error: %v", err) + } + + if err := verify.RoundTrip(input, output); err != nil { + t.Errorf("round-trip failed: %v", err) + } + }) + } +} + +func TestCommentPreservation(t *testing.T) { + t.Parallel() + + testdataDir := filepath.Join(testdataRoot, "format") + + entries, err := os.ReadDir(testdataDir) + if err != nil { + t.Fatalf("reading testdata dir: %v", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := filepath.Join(testdataDir, name) + inputPath := filepath.Join(dir, "input.cdc") + + input, err := os.ReadFile(inputPath) + if err != nil { + t.Fatalf("reading input: %v", err) + } + + output, err := formatter.Format(input, inputPath, formatter.Default()) + if err != nil { + t.Fatalf("format error: %v", err) + } + + // Extract comment texts from input and output + inputComments := commentTexts(input) + outputComments := commentTexts(output) + + if len(inputComments) == 0 { + return // no comments to check + } + + // Compare as sorted multisets + sort.Strings(inputComments) + sort.Strings(outputComments) + + if strings.Join(inputComments, "\n") != strings.Join(outputComments, "\n") { + t.Errorf("comment preservation failed.\ninput comments: %v\noutput comments: %v", + inputComments, outputComments) + } + }) + } +} + +func TestKeepBlankLines_Zero(t *testing.T) { + t.Parallel() + src := []byte("access(all) fun a() {}\n\n\naccess(all) fun b() {}\n") + opts := formatter.Default() + opts.KeepBlankLines = 0 + got, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("format error: %v", err) + } + if strings.Contains(string(got), "\n\n") { + t.Errorf("expected no blank lines with KeepBlankLines=0, got:\n%s", got) + } +} + +func TestKeepBlankLines_Two(t *testing.T) { + t.Parallel() + src := []byte("access(all) fun a() {}\n\n\n\n\naccess(all) fun b() {}\n") + opts := formatter.Default() + opts.KeepBlankLines = 2 + got, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("format error: %v", err) + } + if strings.Contains(string(got), "\n\n\n\n") { + t.Errorf("expected at most 2 blank lines with KeepBlankLines=2, got:\n%s", got) + } +} + +func TestKeepBlankLines_Default(t *testing.T) { + t.Parallel() + src := []byte("access(all) fun a() {}\n\n\n\n\naccess(all) fun b() {}\n") + got, err := formatter.Format(src, "test.cdc", formatter.Default()) + if err != nil { + t.Fatalf("format error: %v", err) + } + if strings.Contains(string(got), "\n\n\n") { + t.Errorf("expected at most 1 blank line with default options, got:\n%s", got) + } +} + +func TestAccessModifierComment_FuzzCase(t *testing.T) { + t.Parallel() + src := []byte("contract A{access(A)event00(\nA\n//\n:A)}") + first, err := formatter.Format(src, "test.cdc", formatter.Default()) + if err != nil { + t.Fatalf("first format: %v", err) + } + second, err := formatter.Format(first, "test.cdc", formatter.Default()) + if err != nil { + t.Fatalf("second format: %v", err) + } + if string(first) != string(second) { + t.Errorf("not idempotent.\n--- first ---\n%s\n--- second ---\n%s", + first, second) + } +} + +func TestAccessModifierComment_ContractBody(t *testing.T) { + t.Parallel() + src := []byte("access(A)contract A{A(//\n)}") + first, err := formatter.Format(src, "test.cdc", formatter.Default()) + if err != nil { + t.Fatalf("first format: %v", err) + } + second, err := formatter.Format(first, "test.cdc", formatter.Default()) + if err != nil { + t.Fatalf("second format: %v", err) + } + if string(first) != string(second) { + t.Errorf("not idempotent.\n--- first ---\n%s\n--- second ---\n%s", + first, second) + } +} + +func TestStripSemicolons_Default(t *testing.T) { + t.Parallel() + src := []byte("access(all) let x: Int = 1;\n") + got, err := formatter.Format(src, "test.cdc", formatter.Default()) + if err != nil { + t.Fatalf("format error: %v", err) + } + if strings.Contains(string(got), ";") { + t.Errorf("expected semicolons stripped by default, got:\n%s", got) + } +} + +func TestStripSemicolons_False(t *testing.T) { + t.Parallel() + src := []byte("access(all) let x: Int = 1;\n") + opts := formatter.Default() + opts.StripSemicolons = false + got, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("format error: %v", err) + } + if !strings.Contains(string(got), ";") { + t.Errorf("expected semicolons preserved with StripSemicolons=false, got:\n%s", got) + } +} + +func TestStripSemicolons_Idempotent(t *testing.T) { + t.Parallel() + src := []byte("access(all) let x: Int = 1;\n") + opts := formatter.Default() + opts.StripSemicolons = false + first, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("first format error: %v", err) + } + second, err := formatter.Format(first, "test.cdc", opts) + if err != nil { + t.Fatalf("second format error: %v", err) + } + if string(first) != string(second) { + t.Errorf("not idempotent with StripSemicolons=false.\n--- first ---\n%s\n--- second ---\n%s", + first, second) + } +} + +func TestFormatVersion_Unsupported(t *testing.T) { + t.Parallel() + opts := formatter.Default() + opts.FormatVersion = "99" + _, err := formatter.Format([]byte("access(all) fun main() {}"), "test.cdc", opts) + if err == nil { + t.Fatal("expected error for unsupported format version") + } + if !strings.Contains(err.Error(), "unsupported format version") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFormatVersion_Current(t *testing.T) { + t.Parallel() + _, err := formatter.Format([]byte("access(all) fun main() {}"), "test.cdc", formatter.Default()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- Indent option tests --- + +func TestIndent_Default(t *testing.T) { + t.Parallel() + src := []byte("access(all) fun main() {\nlet x = 1\n}\n") + got, err := formatter.Format(src, "test.cdc", formatter.Default()) + if err != nil { + t.Fatalf("format error: %v", err) + } + if !strings.Contains(string(got), "\n let x") { + t.Errorf("expected 4-space indent, got:\n%s", got) + } +} + +func TestIndent_TwoSpaces(t *testing.T) { + t.Parallel() + src := []byte("access(all) fun main() {\nlet x = 1\n}\n") + opts := formatter.Default() + opts.IndentCount = 2 + got, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("format error: %v", err) + } + if !strings.Contains(string(got), "\n let x") { + t.Errorf("expected 2-space indent, got:\n%s", got) + } + if strings.Contains(string(got), "\n let x") { + t.Errorf("should not have 4-space indent, got:\n%s", got) + } +} + +func TestIndent_ThreeSpaces(t *testing.T) { + t.Parallel() + src := []byte("access(all) fun main() {\nlet x = 1\n}\n") + opts := formatter.Default() + opts.IndentCount = 3 + got, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("format error: %v", err) + } + if !strings.Contains(string(got), "\n let x") { + t.Errorf("expected 3-space indent, got:\n%s", got) + } +} + +func TestIndent_Tabs(t *testing.T) { + t.Parallel() + src := []byte("access(all) fun main() {\nlet x = 1\n}\n") + opts := formatter.Default() + opts.IndentCharacter = "\t" + opts.IndentCount = 1 + got, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("format error: %v", err) + } + if !strings.Contains(string(got), "\n\tlet x") { + t.Errorf("expected tab indent, got:\n%s", got) + } +} + +func TestIndent_Idempotent(t *testing.T) { + t.Parallel() + src := []byte("access(all) fun main() {\nlet x = 1\n}\n") + opts := formatter.Default() + opts.IndentCount = 2 + first, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("first format: %v", err) + } + second, err := formatter.Format(first, "test.cdc", opts) + if err != nil { + t.Fatalf("second format: %v", err) + } + if string(first) != string(second) { + t.Errorf("not idempotent.\n--- first ---\n%s\n--- second ---\n%s", first, second) + } +} + +func TestIndentCharacter_Invalid(t *testing.T) { + t.Parallel() + opts := formatter.Default() + opts.IndentCharacter = "x" + _, err := formatter.Format([]byte("access(all) fun main() {}"), "test.cdc", opts) + if err == nil { + t.Fatal("expected error for invalid IndentCharacter") + } + if !strings.Contains(err.Error(), "IndentCharacter") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestIndentCount_Zero(t *testing.T) { + t.Parallel() + opts := formatter.Default() + opts.IndentCount = 0 + _, err := formatter.Format([]byte("access(all) fun main() {}"), "test.cdc", opts) + if err == nil { + t.Fatal("expected error for IndentCount=0") + } + if !strings.Contains(err.Error(), "IndentCount") { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- LineWidth option tests --- + +func TestLineWidth_Narrow(t *testing.T) { + t.Parallel() + // This expression fits in 100 cols but not 40 + src := []byte("access(all) fun main(parameterOne: Int, parameterTwo: String) {}\n") + opts := formatter.Default() + opts.LineWidth = 40 + got, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("format error: %v", err) + } + // With narrow width, params should break across lines + if !strings.Contains(string(got), "\n") || strings.Count(string(got), "\n") < 2 { + t.Errorf("expected line break with LineWidth=40, got:\n%s", got) + } +} + +func TestLineWidth_Wide(t *testing.T) { + t.Parallel() + src := []byte("access(all) fun main(parameterOne: Int, parameterTwo: String) {}\n") + opts := formatter.Default() + opts.LineWidth = 200 + got, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("format error: %v", err) + } + // With wide width, should stay on one line (just the declaration + trailing newline) + lines := strings.Split(strings.TrimRight(string(got), "\n"), "\n") + if len(lines) != 1 { + t.Errorf("expected single line with LineWidth=200, got %d lines:\n%s", len(lines), got) + } +} + +// --- SortImports option test --- + +func TestSortImports_True(t *testing.T) { + t.Parallel() + src := []byte("import \"Zebra\"\nimport \"Alpha\"\n\naccess(all) fun main() {}\n") + got, err := formatter.Format(src, "test.cdc", formatter.Default()) + if err != nil { + t.Fatalf("format error: %v", err) + } + alphaIdx := strings.Index(string(got), "\"Alpha\"") + zebraIdx := strings.Index(string(got), "\"Zebra\"") + if alphaIdx > zebraIdx { + t.Errorf("expected imports sorted (Alpha before Zebra), got:\n%s", got) + } +} + +func TestSortImports_False(t *testing.T) { + t.Parallel() + src := []byte("import \"Zebra\"\nimport \"Alpha\"\n\naccess(all) fun main() {}\n") + opts := formatter.Default() + opts.SortImports = false + got, err := formatter.Format(src, "test.cdc", opts) + if err != nil { + t.Fatalf("format error: %v", err) + } + zebraIdx := strings.Index(string(got), "\"Zebra\"") + alphaIdx := strings.Index(string(got), "\"Alpha\"") + if zebraIdx > alphaIdx { + t.Errorf("expected imports to stay unsorted (Zebra before Alpha), got:\n%s", got) + } +} + +// --- SkipVerify option test --- + +func TestSkipVerify(t *testing.T) { + t.Parallel() + opts := formatter.Default() + opts.SkipVerify = true + _, err := formatter.Format([]byte("access(all) fun main() {}"), "test.cdc", opts) + if err != nil { + t.Fatalf("unexpected error with SkipVerify=true: %v", err) + } +} + +func commentTexts(src []byte) []string { + comments := trivia.Scan(src) + texts := make([]string, len(comments)) + for i, c := range comments { + // Normalize: strip trailing whitespace from each line within the + // comment, so blank lines inside block comments compare equal + // regardless of indentation whitespace. + lines := strings.Split(c.Text, "\n") + for j, line := range lines { + lines[j] = strings.TrimRight(line, " \t") + } + texts[i] = strings.Join(lines, "\n") + } + return texts +} + +func TestNoTrailingWhitespace(t *testing.T) { + t.Parallel() + + testdataDir := filepath.Join(testdataRoot, "format") + entries, err := os.ReadDir(testdataDir) + if err != nil { + t.Fatalf("reading testdata dir: %v", err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + t.Run(name, func(t *testing.T) { + t.Parallel() + input, err := os.ReadFile(filepath.Join(testdataDir, name, "input.cdc")) + if err != nil { + t.Fatalf("reading input: %v", err) + } + got, err := formatter.Format(input, "test.cdc", formatter.Default()) + if err != nil { + t.Fatalf("format error: %v", err) + } + for i, line := range strings.Split(string(got), "\n") { + trimmed := strings.TrimRight(line, " \t") + if trimmed != line { + t.Errorf("line %d has trailing whitespace: %q", i+1, line) + } + } + }) + } +} diff --git a/formatter/fuzz_test.go b/formatter/fuzz_test.go new file mode 100644 index 000000000..51748e0b2 --- /dev/null +++ b/formatter/fuzz_test.go @@ -0,0 +1,87 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package formatter_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/onflow/cadence/formatter" +) + +// FuzzFormat feeds arbitrary bytes and asserts no panics. +// Parse errors are expected and ignored. +func FuzzFormat(f *testing.F) { + // Seed with snapshot test inputs + seedFromTestdata(f) + + f.Fuzz(func(t *testing.T, data []byte) { + // Must not panic on any input + _, _ = formatter.Format(data, "fuzz.cdc", formatter.Default()) + }) +} + +// FuzzRoundtrip feeds bytes that parse successfully and asserts +// idempotence (format twice, compare). +func FuzzRoundtrip(f *testing.F) { + seedFromTestdata(f) + + f.Fuzz(func(t *testing.T, data []byte) { + first, err := formatter.Format(data, "fuzz.cdc", formatter.Default()) + if err != nil { + return // parse errors are fine + } + + opts := formatter.Default() + opts.SkipVerify = true // already verified in first pass + second, err := formatter.Format(first, "fuzz.cdc", opts) + if err != nil { + t.Fatalf("second format failed: %v", err) + } + + if string(first) != string(second) { + t.Errorf("not idempotent.\n--- first (%d bytes) ---\n%s\n--- second (%d bytes) ---\n%s", + len(first), first, len(second), second) + } + }) +} + +func seedFromTestdata(f *testing.F) { + f.Helper() + root := findRepoRoot(f) + testdataDir := filepath.Join(root, "testdata", "format") + + entries, err := os.ReadDir(testdataDir) + if err != nil { + return + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + inputPath := filepath.Join(testdataDir, entry.Name(), "input.cdc") + data, err := os.ReadFile(inputPath) + if err != nil { + continue + } + f.Add(data) + } +} diff --git a/formatter/options.go b/formatter/options.go new file mode 100644 index 000000000..ac47e9a56 --- /dev/null +++ b/formatter/options.go @@ -0,0 +1,68 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package formatter + +import "fmt" + +// CurrentFormatVersion identifies the formatting algorithm version. +// Bump when rewrite pass order changes or formatting rules change. +const CurrentFormatVersion = "1" + +// Options controls formatting behavior. All fields have sensible defaults +// via Default(). +type Options struct { + LineWidth int + IndentCharacter string // " " or "\t" + IndentCount int + SortImports bool + StripSemicolons bool + KeepBlankLines int + FormatVersion string + SkipVerify bool +} + +// Validate checks that the Options are valid. +func (o Options) Validate() error { + if o.FormatVersion != CurrentFormatVersion { + return fmt.Errorf("unsupported format version %q (current: %s)", o.FormatVersion, CurrentFormatVersion) + } + if o.IndentCharacter != " " && o.IndentCharacter != "\t" { + return fmt.Errorf("IndentCharacter must be %q or %q, got %q", " ", "\t", o.IndentCharacter) + } + if o.IndentCount < 1 { + return fmt.Errorf("IndentCount must be >= 1, got %d", o.IndentCount) + } + if o.KeepBlankLines < 0 { + return fmt.Errorf("KeepBlankLines must be >= 0, got %d", o.KeepBlankLines) + } + return nil +} + +// Default returns the canonical default formatting options. +func Default() Options { + return Options{ + LineWidth: 100, + IndentCharacter: " ", + IndentCount: 4, + SortImports: true, + StripSemicolons: true, + KeepBlankLines: 1, + FormatVersion: CurrentFormatVersion, + } +} diff --git a/formatter/render/decl.go b/formatter/render/decl.go new file mode 100644 index 000000000..b40a0fbae --- /dev/null +++ b/formatter/render/decl.go @@ -0,0 +1,941 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package render + +import ( + "github.com/turbolent/prettier" + + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/common" + "github.com/onflow/cadence/formatter/trivia" +) + +// hasBlankLineBetween checks the source bytes between two statements for a +// blank line (a line containing only whitespace). This is more reliable than +// comparing AST line numbers, which can be inaccurate for multi-line expressions. +// Must be called BEFORE cm.Take() drains comments, since it uses comment +// positions to narrow the byte range. +func (r *renderer) hasBlankLineBetween(prev, curr ast.Statement) bool { + if len(r.source) == 0 { + return false + } + + // Find the last byte offset of prev (including trailing comments). + endOffset := prev.EndPosition(nil).Offset + if trailing := r.cm.Trailing[prev]; len(trailing) > 0 { + if tEnd := trailing[len(trailing)-1].EndPos().Offset; tEnd > endOffset { + endOffset = tEnd + } + } + + // Find the first byte offset of curr (including leading comments). + startOffset := curr.StartPosition().Offset + if leading := r.cm.Leading[curr]; len(leading) > 0 { + if lStart := leading[0].StartPos().Offset; lStart < startOffset { + startOffset = lStart + } + } + + // Scan the source bytes between the two positions for a blank line: + // two newlines with only whitespace between them. + if endOffset >= startOffset || endOffset >= len(r.source) { + return false + } + sawNewline := false + for i := endOffset; i < startOffset && i < len(r.source); i++ { + b := r.source[i] + if b == '\n' { + if sawNewline { + return true + } + sawNewline = true + } else if b != ' ' && b != '\t' && b != '\r' { + sawNewline = false + } + } + return false +} + +// declaration dispatches to a custom renderer for the declaration type +// if we need to override the upstream Doc() behavior, otherwise falls back +// to the default Doc(). +func (r *renderer) declaration(decl ast.Declaration) prettier.Doc { + var doc prettier.Doc + + switch d := decl.(type) { + case *ast.FunctionDeclaration: + doc = r.function(d) + case *ast.CompositeDeclaration: + doc = r.composite(d) + case *ast.InterfaceDeclaration: + doc = r.interfaceDecl(d) + case *ast.VariableDeclaration: + doc = r.variable(d) + case *ast.FieldDeclaration: + doc = r.field(d) + case *ast.SpecialFunctionDeclaration: + doc = r.specialFunction(d) + case *ast.EntitlementMappingDeclaration: + doc = r.entitlementMapping(d) + case *ast.TransactionDeclaration: + doc = r.transaction(d) + default: + // For unknown declaration types, use upstream Doc() and drain + // any descendant comments so they're not orphaned. + doc = decl.Doc() + return r.wrapAllComments(decl, doc) + } + + // Drain any remaining descendant comments (e.g., NominalType nodes + // inside entitlement access modifiers) that specific renderers didn't take. + r.drainDescendants(decl, nil) + + doc = r.wrapComments(decl, doc) + if r.hasSemicolon(decl) { + doc = prettier.Concat{doc, prettier.Text(";")} + } + return doc +} + +// access renders an access modifier and takes any comments attached +// to its child NominalType nodes (entitlement types). Comments are rendered +// between the access modifier and the following keyword. +func (r *renderer) access(access ast.Access) prettier.Doc { + if access == ast.AccessNotSpecified { + return nil + } + // Drain comments from entitlement NominalType children so they don't + // become orphaned. These comments are on AST nodes that the upstream + // Access.Doc() renders as flat text (e.g., "access(A)"), so there's + // no natural position for them in the output. + access.Walk(func(child ast.Element) { + if child == nil { + return + } + r.cm.Take(child) + }) + return prettier.Concat{access.Doc(), prettier.Space} +} + +// function renders a function declaration with access on the same line. +func (r *renderer) function(d *ast.FunctionDeclaration) prettier.Doc { + parts := prettier.Concat{} + + // Access modifier + if d.Access != ast.AccessNotSpecified { + parts = append(parts, r.access(d.Access)) + } + + // Purity (view) + if d.Purity != ast.FunctionPurityUnspecified { + parts = append(parts, prettier.Text(d.Purity.Keyword()), prettier.Space) + } + + // Static/native flags + if d.IsStatic() { + parts = append(parts, prettier.Text("static"), prettier.Space) + } + if d.IsNative() { + parts = append(parts, prettier.Text("native"), prettier.Space) + } + + // "fun" keyword + name + parts = append(parts, prettier.Text("fun"), prettier.Space) + parts = append(parts, prettier.Text(d.Identifier.Identifier)) + + // Type parameters + if d.TypeParameterList != nil { + parts = append(parts, d.TypeParameterList.Doc()) + } + + // Parameters — use custom rendering to preserve comments between params + if d.ParameterList != nil { + paramDoc, _ := r.parameterList(d.ParameterList) + parts = append(parts, paramDoc) + } + + // Return type + if d.ReturnTypeAnnotation != nil && d.ReturnTypeAnnotation.Type != nil { + parts = append(parts, prettier.Text(": "), r.wrapAllComments(d.ReturnTypeAnnotation, d.ReturnTypeAnnotation.Doc())) + } + + // Function body + if d.FunctionBlock != nil { + parts = append(parts, prettier.Space, r.functionBlock(d.FunctionBlock)) + } + + return parts +} + +// functionBlock renders a { pre { } post { } stmts } block with +// comment interleaving between statements. +func (r *renderer) functionBlock(b *ast.FunctionBlock) prettier.Doc { + if b.IsEmpty() { + return prettier.Text("{}") + } + + body := prettier.Concat{} + needSep := false + + // Pre-conditions + if b.PreConditions != nil && !b.PreConditions.IsEmpty() { + condDoc := b.PreConditions.Doc(prettier.Text("pre")) + r.drainConditions(b.PreConditions) + body = append(body, condDoc) + needSep = true + } + + // Post-conditions + if b.PostConditions != nil && !b.PostConditions.IsEmpty() { + if needSep { + body = append(body, prettier.HardLine{}) + } + condDoc := b.PostConditions.Doc(prettier.Text("post")) + r.drainConditions(b.PostConditions) + body = append(body, condDoc) + needSep = true + } + + // Statements + if b.Block != nil { + // Drain any comments attached to the Block node itself + // (e.g., comments inside post{} blocks in interface functions) + leading, _, trailing := r.cm.Take(b.Block) + for _, g := range leading { + if needSep { + body = append(body, prettier.HardLine{}) + } + body = append(body, renderCommentGroup(g)) + needSep = true + } + // Pre-compute blank line flags before rendering drains the CommentMap. + stmts := b.Block.Statements + blankBefore := make([]bool, len(stmts)) + for i := 1; i < len(stmts); i++ { + blankBefore[i] = r.hasBlankLineBetween(stmts[i-1], stmts[i]) + } + for i, stmt := range stmts { + if needSep { + body = append(body, prettier.HardLine{}) + if blankBefore[i] { + body = append(body, prettier.HardLine{}) + } + } + body = append(body, r.statement(stmt)) + needSep = true + } + for _, g := range trailing { + if needSep { + body = append(body, prettier.HardLine{}) + } + body = append(body, renderCommentGroup(g)) + needSep = true + } + } + + return prettier.Concat{ + prettier.Text("{"), + prettier.Indent{Doc: prettier.Concat{ + prettier.HardLine{}, + body, + }}, + prettier.HardLine{}, + prettier.Text("}"), + } +} + +// statement dispatches to custom renderers for specific statement types, +// otherwise falls back to the upstream Doc(). +func (r *renderer) statement(stmt ast.Statement) prettier.Doc { + var doc prettier.Doc + switch s := stmt.(type) { + case *ast.ReturnStatement: + doc = r.wrapComments(s, r.returnStatement(s)) + case *ast.ForStatement: + doc = r.wrapComments(s, r.forStatement(s)) + case *ast.WhileStatement: + doc = r.wrapComments(s, r.whileStatement(s)) + case *ast.IfStatement: + doc = r.wrapComments(s, r.ifStatement(s)) + case *ast.VariableDeclaration: + doc = r.wrapComments(s, r.variable(s)) + case *ast.AssignmentStatement: + doc = r.wrapComments(s, r.assignmentStatement(s)) + case *ast.ExpressionStatement: + doc = r.wrapComments(s, r.expression(s.Expression)) + default: + doc = r.wrapAllComments(stmt, stmt.Doc()) + } + if r.hasSemicolon(stmt) { + doc = prettier.Concat{doc, prettier.Text(";")} + } + return doc +} + +// block renders the body of a block by iterating statements and +// interleaving comments. Returns the body content without braces. +func (r *renderer) block(b *ast.Block) prettier.Doc { + if b == nil || len(b.Statements) == 0 { + return nil + } + + blankBefore := make([]bool, len(b.Statements)) + for i := 1; i < len(b.Statements); i++ { + blankBefore[i] = r.hasBlankLineBetween(b.Statements[i-1], b.Statements[i]) + } + + body := prettier.Concat{} + for i, stmt := range b.Statements { + if i > 0 { + body = append(body, prettier.HardLine{}) + if blankBefore[i] { + body = append(body, prettier.HardLine{}) + } + } + body = append(body, r.statement(stmt)) + } + return body +} + +// blockBraces wraps a block body in { ... } with indentation. +func (r *renderer) blockBraces(b *ast.Block) prettier.Doc { + body := r.block(b) + if body == nil { + return prettier.Text("{}") + } + return prettier.Concat{ + prettier.Text("{"), + prettier.Indent{Doc: prettier.Concat{ + prettier.HardLine{}, + body, + }}, + prettier.HardLine{}, + prettier.Text("}"), + } +} + +// forStatement renders a for-in loop with comment interleaving in the body. +func (r *renderer) forStatement(s *ast.ForStatement) prettier.Doc { + parts := prettier.Concat{} + + parts = append(parts, prettier.Text("for ")) + parts = append(parts, prettier.Text(s.Identifier.Identifier)) + parts = append(parts, prettier.Text(" in ")) + parts = append(parts, r.expression(s.Value)) + parts = append(parts, prettier.Space) + parts = append(parts, r.blockBraces(s.Block)) + + return parts +} + +// whileStatement renders a while loop with comment interleaving in the body. +func (r *renderer) whileStatement(s *ast.WhileStatement) prettier.Doc { + parts := prettier.Concat{} + + parts = append(parts, prettier.Text("while ")) + parts = append(parts, r.expression(s.Test)) + parts = append(parts, prettier.Space) + parts = append(parts, r.blockBraces(s.Block)) + + return parts +} + +// ifStatement renders an if/else-if/else chain with comment interleaving. +func (r *renderer) ifStatement(s *ast.IfStatement) prettier.Doc { + parts := prettier.Concat{} + + parts = append(parts, prettier.Text("if ")) + parts = append(parts, r.wrapAllComments(s.Test, s.Test.Doc())) + parts = append(parts, prettier.Space) + parts = append(parts, r.blockBraces(s.Then)) + + if s.Else != nil && len(s.Else.Statements) > 0 { + // Check if the else block is a single if-statement (else-if chain) + if len(s.Else.Statements) == 1 { + if elseIf, ok := s.Else.Statements[0].(*ast.IfStatement); ok { + parts = append(parts, prettier.Text(" else ")) + parts = append(parts, r.wrapComments(elseIf, r.ifStatement(elseIf))) + return parts + } + } + parts = append(parts, prettier.Text(" else ")) + parts = append(parts, r.blockBraces(s.Else)) + } + + return parts +} + +// assignmentStatement renders target = value without the upstream's +// extra Indent wrapper that over-indents function call arguments. +func (r *renderer) assignmentStatement(s *ast.AssignmentStatement) prettier.Doc { + parts := prettier.Concat{} + + parts = append(parts, r.expression(s.Target)) + parts = append(parts, prettier.Space) + parts = append(parts, s.Transfer.Doc()) + parts = append(parts, prettier.Space) + parts = append(parts, r.expression(s.Value)) + + return parts +} + +// returnStatement renders a return statement. For binary expressions +// (e.g., ?? nil-coalescing), wraps in Indent so continuation lines are +// indented relative to "return". Other expressions render directly to +// avoid over-indenting function call arguments. +func (r *renderer) returnStatement(s *ast.ReturnStatement) prettier.Doc { + if s.Expression == nil { + return prettier.Text("return") + } + + // Binary expressions need Indent for proper continuation line indentation. + // Drain descendant comments outside the Indent so they don't pick up + // expression-level indentation that isn't stable across re-formats. + if _, ok := s.Expression.(*ast.BinaryExpression); ok { + exprDoc := r.wrapComments(s.Expression, s.Expression.Doc()) + parts := prettier.Concat{ + prettier.Text("return "), + prettier.Indent{Doc: exprDoc}, + } + var extras []prettier.Doc + r.drainDescendants(s.Expression, &extras) + for _, e := range extras { + parts = append(parts, prettier.HardLine{}, e) + } + return parts + } + + return prettier.Concat{ + prettier.Text("return "), + r.expression(s.Expression), + } +} + +// composite renders a composite declaration (resource, struct, contract, etc.) +// with access on the same line. +func (r *renderer) composite(d *ast.CompositeDeclaration) prettier.Doc { + // Events use a special compact format (no members block with braces) + if d.CompositeKind == common.CompositeKindEvent { + return r.event(d) + } + + parts := prettier.Concat{} + + // Access modifier + if d.Access != ast.AccessNotSpecified { + parts = append(parts, r.access(d.Access)) + } + + // Kind keyword + parts = append(parts, prettier.Text(d.CompositeKind.Keyword()), prettier.Space) + + // Name + parts = append(parts, prettier.Text(d.Identifier.Identifier)) + + // Conformances + parts = r.conformances(parts, d.Conformances, d.Members) + + // Members + parts = append(parts, r.membersBlock(d.Members)) + return parts +} + +// conformances appends a comma-separated conformance list (": A, B, C") +// to parts, draining each conformance's comments. Trailing comments on a +// conformance are hoisted onto the leading slot of the first member, since +// they logically describe the first field, not the conformance type. +// The upstream ast.Walk yields conformances as children, so the trivia layer +// may attach comments to them; we must drain so they don't become orphaned. +func (r *renderer) conformances( + parts prettier.Concat, + conformances []*ast.NominalType, + members *ast.Members, +) prettier.Concat { + if len(conformances) == 0 { + return parts + } + parts = append(parts, prettier.Text(":"), prettier.Space) + for i, c := range conformances { + if i > 0 { + parts = append(parts, prettier.Text(","), prettier.Space) + } + parts = append(parts, c.Doc()) + _, _, trailing := r.cm.Take(c) + if len(trailing) > 0 && members != nil { + decls := members.Declarations() + if len(decls) > 0 { + r.cm.Leading[decls[0]] = append(trailing, r.cm.Leading[decls[0]]...) + } + } + } + return parts +} + +// event renders an event declaration with comments interleaved between +// parameters. The upstream EventDoc() + drain approach displaces parameter +// comments outside the closing paren. +func (r *renderer) event(d *ast.CompositeDeclaration) prettier.Doc { + parts := prettier.Concat{} + + // Access modifier + if d.Access != ast.AccessNotSpecified { + parts = append(parts, r.access(d.Access)) + } + + // "event Name" + parts = append(parts, prettier.Text(d.CompositeKind.Keyword()), prettier.Space) + parts = append(parts, prettier.Text(d.Identifier.Identifier)) + + // Get parameters from the event's initializer + initializers := d.Members.Initializers() + if len(initializers) != 1 { + // Fallback: no valid initializer, use upstream + r.drainDescendants(d, nil) + return parts + } + + paramList := initializers[0].FunctionDeclaration.ParameterList + paramDoc, _ := r.parameterList(paramList) + parts = append(parts, paramDoc) + + // Drain any remaining descendant comments (type annotations, etc.) + r.drainDescendants(d, nil) + + return parts +} + +// transaction renders a transaction declaration with comment +// interleaving inside prepare/execute blocks. Without this, the default +// wrapAllComments path drains all block-interior comments and appends +// them after the closing brace. +func (r *renderer) transaction(d *ast.TransactionDeclaration) prettier.Doc { + doc := prettier.Concat{prettier.Text("transaction")} + + // Parameters + paramDoc, paramTrailing := r.parameterList(d.ParameterList) + doc = append(doc, paramDoc) + + // Move trailing comments from last parameter to leading of first field + if len(paramTrailing) > 0 && len(d.Fields) > 0 { + r.cm.Leading[d.Fields[0]] = append(paramTrailing, r.cm.Leading[d.Fields[0]]...) + } + + // Build body contents + var contents []prettier.Doc + + // Fields + for _, field := range d.Fields { + contents = append(contents, r.declaration(field)) + } + + // Prepare block + if d.Prepare != nil { + contents = append(contents, r.declaration(d.Prepare)) + } + + // Pre-conditions + if d.PreConditions != nil && !d.PreConditions.IsEmpty() { + condDoc := d.PreConditions.Doc(prettier.Text("pre")) + r.drainWalk(d.PreConditions.Walk) + contents = append(contents, condDoc) + } + + // Execute block + if d.Execute != nil { + contents = append(contents, r.declaration(d.Execute)) + } + + // Post-conditions + if d.PostConditions != nil && !d.PostConditions.IsEmpty() { + condDoc := d.PostConditions.Doc(prettier.Text("post")) + r.drainWalk(d.PostConditions.Walk) + contents = append(contents, condDoc) + } + + // Build the braced body + if len(contents) == 0 { + doc = append(doc, prettier.Text(" {}")) + return doc + } + + body := prettier.Concat{} + for i, content := range contents { + if i > 0 { + body = append(body, prettier.HardLine{}) + } + body = append(body, content) + } + + doc = append(doc, + prettier.Space, + prettier.Text("{"), + prettier.Indent{Doc: prettier.Concat{ + prettier.HardLine{}, + body, + }}, + prettier.HardLine{}, + prettier.Text("}"), + ) + + return doc +} + +// paramInfo holds a rendered parameter and its associated comments. +type paramInfo struct { + doc prettier.Doc + leading []*trivia.CommentGroup + sameLine *trivia.CommentGroup + trailing []*trivia.CommentGroup +} + +// parameterList renders a function/event parameter list with comments +// interleaved between parameters. ParameterList.Walk() yields TypeAnnotation +// nodes (not Parameter), so comments are attached to TypeAnnotation nodes. +// Returns the rendered doc and any trailing comments from the last parameter +// that the caller should place after the parameter list. +func (r *renderer) parameterList(paramList *ast.ParameterList) (prettier.Doc, []*trivia.CommentGroup) { + if paramList == nil || len(paramList.Parameters) == 0 { + r.drainWalk(paramList.Walk) + return prettier.Text("()"), nil + } + + // Collect parameters with their comments + params := make([]paramInfo, len(paramList.Parameters)) + hasComments := false + var pendingTrailing []*trivia.CommentGroup + + for i, param := range paramList.Parameters { + p := paramInfo{doc: param.Doc()} + if param.TypeAnnotation != nil { + leading, sameLine, trailing := r.cm.Take(param.TypeAnnotation) + p.leading = append(pendingTrailing, leading...) //nolint:gocritic + p.sameLine = sameLine + p.trailing = trailing + if len(p.leading) > 0 || p.sameLine != nil { + hasComments = true + } + pendingTrailing = trailing + } else { + if len(pendingTrailing) > 0 { + p.leading = pendingTrailing + hasComments = true + } + pendingTrailing = nil + } + params[i] = p + } + + if !hasComments { + // No comments: use upstream soft-breaking layout + r.drainWalk(paramList.Walk) + return paramList.Doc(), pendingTrailing + } + + // Drain any remaining descendant comments (e.g., on NominalType children + // of TypeAnnotation nodes) so they don't become orphaned. + r.drainWalk(paramList.Walk) + + // Comments present: force parameters to break across lines. + // Same-line comments go after the comma on the same line. + inner := prettier.Concat{} + for i, p := range params { + if i > 0 { + inner = append(inner, prettier.Text(",")) + // Previous param's same-line comment after comma + if params[i-1].sameLine != nil { + inner = append(inner, prettier.Text(" "), renderCommentGroup(params[i-1].sameLine)) + } + inner = append(inner, prettier.HardLine{}) + } + // Leading comments for this param + for _, g := range p.leading { + inner = append(inner, renderCommentGroup(g), prettier.HardLine{}) + } + inner = append(inner, p.doc) + } + // Last param's same-line comment + lastParam := params[len(params)-1] + if lastParam.sameLine != nil { + inner = append(inner, prettier.Text(" "), renderCommentGroup(lastParam.sameLine)) + } + + return prettier.Concat{ + prettier.Text("("), + prettier.Indent{Doc: prettier.Concat{ + prettier.HardLine{}, + inner, + }}, + prettier.HardLine{}, + prettier.Text(")"), + }, pendingTrailing +} + +// interfaceDecl renders an interface declaration with access on the same line. +func (r *renderer) interfaceDecl(d *ast.InterfaceDeclaration) prettier.Doc { + parts := prettier.Concat{} + + if d.Access != ast.AccessNotSpecified { + parts = append(parts, r.access(d.Access)) + } + + parts = append(parts, prettier.Text(d.CompositeKind.Keyword()), prettier.Space) + parts = append(parts, prettier.Text("interface"), prettier.Space) + parts = append(parts, prettier.Text(d.Identifier.Identifier)) + + parts = r.conformances(parts, d.Conformances, d.Members) + + parts = append(parts, r.membersBlock(d.Members)) + return parts +} + +// membersBlock renders a { members } block with each member using +// our custom declaration renderers. +func (r *renderer) membersBlock(members *ast.Members) prettier.Doc { + if members == nil { + return prettier.Text(" {}") + } + + decls := members.Declarations() + if len(decls) == 0 { + return prettier.Text(" {}") + } + + body := prettier.Concat{} + for i, decl := range decls { + if i > 0 { + body = append(body, prettier.HardLine{}, prettier.HardLine{}) + } + body = append(body, r.declaration(decl)) + } + + return prettier.Concat{ + prettier.Space, + prettier.Text("{"), + prettier.Indent{Doc: prettier.Concat{ + prettier.HardLine{}, + body, + }}, + prettier.HardLine{}, + prettier.Text("}"), + } +} + +// variable renders a variable declaration with access on the same line. +func (r *renderer) variable(d *ast.VariableDeclaration) prettier.Doc { + parts := prettier.Concat{} + + // Access modifier + if d.Access != ast.AccessNotSpecified { + parts = append(parts, r.access(d.Access)) + } + + // let/var keyword + if d.IsConstant { + parts = append(parts, prettier.Text("let"), prettier.Space) + } else { + parts = append(parts, prettier.Text("var"), prettier.Space) + } + + // Identifier + parts = append(parts, prettier.Text(d.Identifier.Identifier)) + + // Type annotation. If the type annotation has same-line or trailing `//` + // line comments AND there is a value, hoist them to leading of the value: + // otherwise the type's `//` renders followed by ` = ` on the same + // doc line, and the comment swallows the assignment in the output. + if d.TypeAnnotation != nil && d.TypeAnnotation.Type != nil { + if d.Value != nil { + // Move in reverse source order so the prepends produce source order: + // trailing comments are between the type and value, same-line is on + // the type's own line (earlier in source than trailing). + r.cm.MoveTrailingToLeading(d.TypeAnnotation, d.Value) + r.cm.MoveSameLineToLeading(d.TypeAnnotation, d.Value) + } + parts = append(parts, prettier.Text(": "), r.wrapAllComments(d.TypeAnnotation, d.TypeAnnotation.Doc())) + } + + // Transfer and value + if d.Value != nil { + parts = append(parts, prettier.Space) + parts = append(parts, prettier.Text(d.Transfer.Operation.Operator())) + // Peek before rendering since renderExpression / wrapWithComments + // drains the value's leading comments. + valueHasLineComment := r.cm.HasLeadingLineComment(d.Value) + // Binary expressions (e.g., ?? nil-coalescing) need Indent for + // continuation line indentation. Other expressions render directly + // to avoid over-indenting function call arguments. + if _, ok := d.Value.(*ast.BinaryExpression); ok { + // Don't use wrapAllComments here — drained descendant + // comments would end up inside the Indent, gaining indentation + // that isn't stable across re-formats. + valueDoc := r.wrapComments(d.Value, d.Value.Doc()) + parts = append(parts, prettier.Group{ + Doc: prettier.Indent{ + Doc: prettier.Concat{ + prettier.Line{}, + valueDoc, + }, + }, + }) + var extras []prettier.Doc + r.drainDescendants(d.Value, &extras) + for _, e := range extras { + parts = append(parts, prettier.HardLine{}, e) + } + } else { + valueDoc := r.expression(d.Value) + if valueHasLineComment { + parts = append(parts, prettier.HardLine{}) + } else { + parts = append(parts, prettier.Space) + } + parts = append(parts, valueDoc) + } + } + + // Second transfer (for swap operations) + if d.SecondValue != nil { + parts = append(parts, prettier.Space) + parts = append(parts, prettier.Text(d.SecondTransfer.Operation.Operator())) + parts = append(parts, prettier.Space) + parts = append(parts, d.SecondValue.Doc()) + } + + return parts +} + +// drainConditions drains any comments attached to Conditions' children. +func (r *renderer) drainConditions(conds *ast.Conditions) { + conds.Walk(func(child ast.Element) { + if child == nil { + return + } + r.cm.Take(child) + var discard []prettier.Doc + r.drainDescendants(child, &discard) + }) +} + +// specialFunction renders init/destroy/prepare declarations. +// These don't use the "fun" keyword. +func (r *renderer) specialFunction(d *ast.SpecialFunctionDeclaration) prettier.Doc { + fn := d.FunctionDeclaration + parts := prettier.Concat{} + + // Access modifier (rare for special functions but possible) + if fn.Access != ast.AccessNotSpecified { + parts = append(parts, r.access(fn.Access)) + } + + // Purity + if fn.Purity != ast.FunctionPurityUnspecified { + parts = append(parts, prettier.Text(fn.Purity.Keyword()), prettier.Space) + } + + // Name (init/destroy/prepare) + parts = append(parts, prettier.Text(fn.Identifier.Identifier)) + + // Parameters — use custom rendering to preserve comments between params + if fn.ParameterList != nil { + paramDoc, _ := r.parameterList(fn.ParameterList) + parts = append(parts, paramDoc) + } + + // Return type + if fn.ReturnTypeAnnotation != nil && fn.ReturnTypeAnnotation.Type != nil { + parts = append(parts, prettier.Text(": "), fn.ReturnTypeAnnotation.Doc()) + } + + // Body + if fn.FunctionBlock != nil { + parts = append(parts, prettier.Space, r.functionBlock(fn.FunctionBlock)) + } + + return parts +} + +// field renders a field declaration (inside composites) with access on the same line. +func (r *renderer) field(d *ast.FieldDeclaration) prettier.Doc { + parts := prettier.Concat{} + + if d.Access != ast.AccessNotSpecified { + parts = append(parts, r.access(d.Access)) + } + + if d.IsStatic() { + parts = append(parts, prettier.Text("static"), prettier.Space) + } + if d.IsNative() { + parts = append(parts, prettier.Text("native"), prettier.Space) + } + + parts = append(parts, prettier.Text(d.VariableKind.Keyword()), prettier.Space) + parts = append(parts, prettier.Text(d.Identifier.Identifier)) + + if d.TypeAnnotation != nil && d.TypeAnnotation.Type != nil { + parts = append(parts, prettier.Text(": "), r.wrapAllComments(d.TypeAnnotation, d.TypeAnnotation.Doc())) + } + + return parts +} + +// entitlementMapping renders an entitlement mapping declaration with +// access on the same line and elements in a braced block. The upstream Doc() +// wraps in Group (fixing access modifier line) but doesn't indent elements. +func (r *renderer) entitlementMapping(d *ast.EntitlementMappingDeclaration) prettier.Doc { + parts := prettier.Concat{} + + if d.Access != ast.AccessNotSpecified { + parts = append(parts, r.access(d.Access)) + } + + parts = append(parts, prettier.Text("entitlement"), prettier.Space) + parts = append(parts, prettier.Text("mapping"), prettier.Space) + parts = append(parts, prettier.Text(d.Identifier.Identifier)) + + if len(d.Elements) == 0 { + parts = append(parts, prettier.Text(" {}")) + return parts + } + + body := prettier.Concat{} + for i, element := range d.Elements { + if i > 0 { + body = append(body, prettier.HardLine{}) + } + if _, isNominalType := element.(*ast.NominalType); isNominalType { + body = append(body, prettier.Text("include "), element.Doc()) + } else if element != nil { + body = append(body, element.Doc()) + } + } + + parts = append(parts, + prettier.Space, + prettier.Text("{"), + prettier.Indent{Doc: prettier.Concat{ + prettier.HardLine{}, + body, + }}, + prettier.HardLine{}, + prettier.Text("}"), + ) + + return parts +} diff --git a/formatter/render/expr.go b/formatter/render/expr.go new file mode 100644 index 000000000..d59f03bd8 --- /dev/null +++ b/formatter/render/expr.go @@ -0,0 +1,255 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package render + +import ( + "strings" + + "github.com/turbolent/prettier" + + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/formatter/trivia" +) + +// expression dispatches to custom renderers for expression types that +// need fixes (invocations with displaced comments, casts with missing indent), +// otherwise falls back to the upstream Doc() with full comment draining. +func (r *renderer) expression(expr ast.Expression) prettier.Doc { + switch e := expr.(type) { + case *ast.InvocationExpression: + return r.wrapComments(e, r.invocationExpression(e)) + case *ast.StringTemplateExpression: + return r.wrapComments(e, r.stringTemplate(e)) + } + return r.wrapAllComments(expr, expr.Doc()) +} + +// argumentDoc renders an invocation argument using our expression renderer +// for the value, so custom expression renderers (string templates, invocations, +// casts) are applied. Mirrors upstream Argument.Doc() structure. +func (r *renderer) argumentDoc(arg *ast.Argument) prettier.Doc { + exprDoc := r.expression(arg.Expression) + if arg.Label == "" { + return exprDoc + } + return prettier.Concat{ + prettier.Text(arg.Label + ": "), + exprDoc, + } +} + +// invocationArg holds a rendered argument and any associated comments that +// must be placed relative to the comma separator (same-line comments go +// after the comma on the same line, not before it). +type invocationArg struct { + doc prettier.Doc // argument rendering (label: expr) + leading []*trivia.CommentGroup // comments before the argument + sameLine *trivia.CommentGroup // same-line comment (after arg, before next) + trailing []*trivia.CommentGroup // comments after the argument + extras []prettier.Doc // drained descendant comment docs +} + +// invocationExpression renders a function call with comments preserved +// inside the argument list. Without this, wrapAllComments + upstream Doc() +// displaces argument comments outside the closing paren. +func (r *renderer) invocationExpression(e *ast.InvocationExpression) prettier.Doc { + parts := prettier.Concat{} + + // Take comments from the invoked expression separately. Trailing comments + // sit between the function name and the opening paren. + leading, sameLine, trailing := r.cm.Take(e.InvokedExpression) + invokedDoc := r.expression(e.InvokedExpression) + + // Re-apply leading and same-line to the invoked expression. + if len(leading) > 0 || sameLine != nil { + wrapped := prettier.Concat{} + for _, g := range leading { + wrapped = append(wrapped, renderCommentGroup(g), prettier.HardLine{}) + } + wrapped = append(wrapped, invokedDoc) + if sameLine != nil { + wrapped = append(wrapped, prettier.Text(" "), renderCommentGroup(sameLine)) + } + invokedDoc = wrapped + } + parts = append(parts, invokedDoc) + + // Type arguments + if len(e.TypeArguments) > 0 { + typeArgDocs := make([]prettier.Doc, len(e.TypeArguments)) + for i, ta := range e.TypeArguments { + typeArgDocs[i] = r.wrapAllComments(ta, ta.Doc()) + } + parts = append(parts, + prettier.Wrap( + prettier.Text("<"), + prettier.Join( + prettier.Concat{prettier.Text(","), prettier.Line{}}, + typeArgDocs..., + ), + prettier.Text(">"), + prettier.SoftLine{}, + ), + ) + } + + // No arguments + if len(e.Arguments) == 0 { + parts = append(parts, prettier.Text("()")) + for _, g := range trailing { + parts = append(parts, prettier.HardLine{}, renderCommentGroup(g)) + } + return parts + } + + // Collect argument docs with their comments + args := make([]invocationArg, len(e.Arguments)) + hasComments := len(trailing) > 0 + for i, arg := range e.Arguments { + // Render the argument using our expression renderer so custom expression + // renderers (e.g., string templates) are applied to argument values. + a := invocationArg{doc: r.argumentDoc(arg)} + + // Collect comments from the Argument element and its Expression. + argLeading, argSameLine, argTrailing := r.cm.Take(arg) + exprLeading, exprSameLine, exprTrailing := r.cm.Take(arg.Expression) + + a.leading = append(argLeading, exprLeading...) //nolint:gocritic + a.trailing = append(argTrailing, exprTrailing...) //nolint:gocritic + // Same-line: prefer argument-level (closer to the text) + a.sameLine = argSameLine + if a.sameLine == nil { + a.sameLine = exprSameLine + } + + // Drain deeper descendants + var extras []prettier.Doc + r.drainDescendants(arg, &extras) + // Convert extras to trailing comment groups (render as-is) + if len(extras) > 0 { + hasComments = true + } + + if len(a.leading) > 0 || a.sameLine != nil || len(a.trailing) > 0 || len(extras) > 0 { + hasComments = true + } + // Store extras as additional trailing docs + a.extras = extras + args[i] = a + } + + if hasComments { + // Comments force arguments to break across lines. + inner := prettier.Concat{} + for _, g := range trailing { + inner = append(inner, renderCommentGroup(g), prettier.HardLine{}) + } + for i, a := range args { + if i > 0 { + inner = append(inner, prettier.Text(",")) + // Previous arg's same-line comment goes after the comma + if args[i-1].sameLine != nil { + inner = append(inner, prettier.Text(" "), renderCommentGroup(args[i-1].sameLine)) + } + inner = append(inner, prettier.HardLine{}) + // Previous arg's trailing comments + for _, g := range args[i-1].trailing { + inner = append(inner, renderCommentGroup(g), prettier.HardLine{}) + } + for _, e := range args[i-1].extras { + inner = append(inner, e, prettier.HardLine{}) + } + } + // Leading comments for this arg + for _, g := range a.leading { + inner = append(inner, renderCommentGroup(g), prettier.HardLine{}) + } + inner = append(inner, a.doc) + } + // Handle last arg's same-line and trailing + lastArg := args[len(args)-1] + if lastArg.sameLine != nil { + inner = append(inner, prettier.Text(" "), renderCommentGroup(lastArg.sameLine)) + } + for _, g := range lastArg.trailing { + inner = append(inner, prettier.HardLine{}, renderCommentGroup(g)) + } + for _, e := range lastArg.extras { + inner = append(inner, prettier.HardLine{}, e) + } + + parts = append(parts, + prettier.Text("("), + prettier.Indent{Doc: prettier.Concat{ + prettier.HardLine{}, + inner, + }}, + prettier.HardLine{}, + prettier.Text(")"), + ) + } else { + // No comments: use soft-breaking argument list + plainDocs := make([]prettier.Doc, len(args)) + for i, a := range args { + plainDocs[i] = a.doc + } + argSep := prettier.Concat{prettier.Text(","), prettier.Line{}} + parts = append(parts, + prettier.WrapParentheses( + prettier.Join(argSep, plainDocs...), + prettier.SoftLine{}, + ), + ) + } + + return parts +} + +// stringTemplate renders a string template with interpolation expressions +// kept flat (no line breaks inside \(...)). The upstream Doc() renders each +// interpolation via expr.Doc() which can include Line{} breaks. We render +// each interpolation as a flat Text node using expr.String(). +func (r *renderer) stringTemplate(e *ast.StringTemplateExpression) prettier.Doc { + if len(e.Expressions) == 0 { + return prettier.Text(ast.QuoteString(e.Values[0])) + } + + concat := make(prettier.Concat, 0, 2+len(e.Values)+(3*len(e.Expressions))) + concat = append(concat, prettier.Text(`"`)) + for i, value := range e.Values { + var sb strings.Builder + ast.QuoteStringInner(value, &sb) + concat = append(concat, prettier.Text(sb.String())) + + if i < len(e.Expressions) { + expr := e.Expressions[i] + // Render interpolation expression as flat text to prevent + // line breaks inside \(...). Drain any comments on it. + r.cm.Take(expr) + r.drainDescendants(expr, nil) + concat = append(concat, + prettier.Text(`\(`), + prettier.Text(expr.String()), + prettier.Text(`)`), + ) + } + } + concat = append(concat, prettier.Text(`"`)) + return concat +} diff --git a/formatter/render/render.go b/formatter/render/render.go new file mode 100644 index 000000000..5d38f6127 --- /dev/null +++ b/formatter/render/render.go @@ -0,0 +1,90 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package render + +import ( + "github.com/turbolent/prettier" + + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/formatter/rewrite" + "github.com/onflow/cadence/formatter/trivia" +) + +// Program renders an *ast.Program with interleaved comments from the CommentMap. +// source is the original input bytes (used for blank-line detection); semicolons +// is nil unless the caller wants explicit ; preserved. +func Program(prog *ast.Program, cm *trivia.CommentMap, source []byte, semicolons map[ast.Element]bool) prettier.Doc { + r := &renderer{cm: cm, source: source, semicolons: semicolons} + return r.program(prog) +} + +func (r *renderer) program(prog *ast.Program) prettier.Doc { + parts := prettier.Concat{} + + // Header comments + header := r.cm.TakeHeader() + for _, g := range header { + parts = append(parts, renderCommentGroup(g), prettier.HardLine{}) + } + if len(header) > 0 { + parts = append(parts, prettier.HardLine{}) + } + + decls := prog.Declarations() + for i, decl := range decls { + if i > 0 { + sep := declSeparation(decls[i-1], decl) + for range sep { + parts = append(parts, prettier.HardLine{}) + } + } + parts = append(parts, r.declaration(decl)) + } + + // Footer comments + footer := r.cm.TakeFooter() + if len(footer) > 0 { + parts = append(parts, prettier.HardLine{}) + } + for _, g := range footer { + parts = append(parts, prettier.HardLine{}, renderCommentGroup(g)) + } + + // Trailing newline + parts = append(parts, prettier.HardLine{}) + + return parts +} + +// declSeparation returns the number of HardLines to insert between +// two consecutive declarations. Imports in the same group get 1 (no blank line); +// imports in different groups or non-imports get 2 (one blank line). +func declSeparation(prev, next ast.Declaration) int { + prevImp, prevIsImport := prev.(*ast.ImportDeclaration) + nextImp, nextIsImport := next.(*ast.ImportDeclaration) + + if prevIsImport && nextIsImport { + if rewrite.ImportGroupOrder(prevImp) == rewrite.ImportGroupOrder(nextImp) { + return 1 // same import group: no blank line + } + return 2 // different import groups: blank line + } + + return 2 // default: blank line between declarations +} diff --git a/formatter/render/renderer.go b/formatter/render/renderer.go new file mode 100644 index 000000000..c018ca8e3 --- /dev/null +++ b/formatter/render/renderer.go @@ -0,0 +1,42 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package render + +import ( + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/formatter/trivia" +) + +// renderer holds state shared across rendering of a single program: the +// CommentMap (drained as comments are emitted), the original source bytes +// (used for blank-line detection that can't rely on AST line numbers), and +// the optional explicit-semicolon set (populated only when StripSemicolons +// is false). All render functions are methods on *renderer so this state +// doesn't need to be threaded through every call. +type renderer struct { + cm *trivia.CommentMap + source []byte + semicolons map[ast.Element]bool +} + +// hasSemicolon reports whether elem had a trailing semicolon in the source. +// Returns false when semicolons is nil (StripSemicolons mode). +func (r *renderer) hasSemicolon(elem ast.Element) bool { + return r.semicolons[elem] +} diff --git a/formatter/render/trivia.go b/formatter/render/trivia.go new file mode 100644 index 000000000..767bf64dc --- /dev/null +++ b/formatter/render/trivia.go @@ -0,0 +1,141 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package render + +import ( + "strings" + + "github.com/turbolent/prettier" + + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/formatter/trivia" +) + +// wrapComments wraps an element's Doc with its leading, same-line, and +// trailing comments from the CommentMap. Comments are removed from the map +// via Take() so each comment is emitted exactly once. +func (r *renderer) wrapComments(elem ast.Element, doc prettier.Doc) prettier.Doc { + leading, sameLine, trailing := r.cm.Take(elem) + + if len(leading) == 0 && sameLine == nil && len(trailing) == 0 { + return doc + } + + parts := prettier.Concat{} + + for _, g := range leading { + parts = append(parts, renderCommentGroup(g), prettier.HardLine{}) + } + + parts = append(parts, doc) + + if sameLine != nil { + parts = append(parts, prettier.Text(" "), renderCommentGroup(sameLine)) + } + + for _, g := range trailing { + parts = append(parts, prettier.HardLine{}, renderCommentGroup(g)) + } + + return parts +} + +// renderCommentGroup renders a group of comments, each on its own line. +func renderCommentGroup(g *trivia.CommentGroup) prettier.Doc { + if len(g.Comments) == 1 { + return renderComment(g.Comments[0]) + } + + parts := prettier.Concat{} + for i, c := range g.Comments { + if i > 0 { + parts = append(parts, prettier.HardLine{}) + } + parts = append(parts, renderComment(c)) + } + return parts +} + +// renderComment renders a single comment. Line comments have trailing +// whitespace trimmed. +func renderComment(c trivia.Comment) prettier.Doc { + text := c.Text + switch c.Kind { + case trivia.KindLine, trivia.KindDocLine: + text = strings.TrimRight(text, " \t") + } + return prettier.Text(text) +} + +// wrapAllComments wraps a node's Doc with its own comments AND drains +// comments from all descendant nodes, emitting them inline. Use this for +// nodes rendered via upstream Doc() where we don't control child rendering. +func (r *renderer) wrapAllComments(elem ast.Element, doc prettier.Doc) prettier.Doc { + doc = r.wrapComments(elem, doc) + var extras []prettier.Doc + r.drainDescendants(elem, &extras) + if len(extras) > 0 { + // Interleave descendant comments into the doc. + // These won't be perfectly positioned but they're preserved. + parts := prettier.Concat{doc} + for _, e := range extras { + parts = append(parts, prettier.HardLine{}, e) + } + return parts + } + return doc +} + +// drainWalk drains comments from all children of a node, given its Walk +// method as a bound function value. Used for ast.ParameterList, ast.Conditions, +// and other non-Element types that can't be passed to drainDescendants directly. +func (r *renderer) drainWalk(walk func(func(ast.Element))) { + walk(func(child ast.Element) { + if child == nil { + return + } + r.cm.Take(child) + var discard []prettier.Doc + r.drainDescendants(child, &discard) + }) +} + +// drainDescendants recursively removes and collects all comments from child +// nodes of elem. If out is nil the comments are still drained from the map +// but discarded. +func (r *renderer) drainDescendants(elem ast.Element, out *[]prettier.Doc) { + elem.Walk(func(child ast.Element) { + if child == nil { + return + } + leading, sameLine, trailing := r.cm.Take(child) + if out != nil { + for _, g := range leading { + *out = append(*out, renderCommentGroup(g)) + } + if sameLine != nil { + *out = append(*out, renderCommentGroup(sameLine)) + } + for _, g := range trailing { + *out = append(*out, renderCommentGroup(g)) + } + } + r.drainDescendants(child, out) + }) +} diff --git a/formatter/rewrite/imports.go b/formatter/rewrite/imports.go new file mode 100644 index 000000000..34bc80835 --- /dev/null +++ b/formatter/rewrite/imports.go @@ -0,0 +1,123 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rewrite + +import ( + "sort" + + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/common" + "github.com/onflow/cadence/formatter/trivia" +) + +type importsSorter struct{} + +func (r *importsSorter) Name() string { return "imports" } + +func (r *importsSorter) Rewrite(prog *ast.Program, _ *trivia.CommentMap) error { + decls := prog.Declarations() + + // Collect import declarations and their indices + var imports []*ast.ImportDeclaration + var indices []int + for i, d := range decls { + if imp, ok := d.(*ast.ImportDeclaration); ok { + imports = append(imports, imp) + indices = append(indices, i) + } + } + + if len(imports) <= 1 { + return nil + } + + // Note: we do not deduplicate imports here because we cannot remove + // declarations from the AST. Dedup would shrink the imports slice but + // leave the declaration at the removed index in place, causing + // non-idempotent output. Stable sort puts duplicates adjacent. + + // Stable sort preserves relative order of equal imports + sort.SliceStable(imports, func(i, j int) bool { + return importLess(imports[i], imports[j]) + }) + + // Place sorted imports back at their original positions + for i, idx := range indices { + decls[idx] = imports[i] + } + + return nil +} + +// ImportGroupOrder returns the sort group for an import: +// 0 = identifier (standard), 1 = address, 2 = string. +func ImportGroupOrder(imp *ast.ImportDeclaration) int { + switch imp.Location.(type) { + case common.IdentifierLocation: + return 0 + case common.AddressLocation: + return 1 + case common.StringLocation: + return 2 + default: + return 3 + } +} + +// importLess defines the sort order for import declarations. +func importLess(a, b *ast.ImportDeclaration) bool { + ga, gb := ImportGroupOrder(a), ImportGroupOrder(b) + if ga != gb { + return ga < gb + } + + switch la := a.Location.(type) { + case common.IdentifierLocation: + lb := b.Location.(common.IdentifierLocation) + return string(la) < string(lb) + case common.AddressLocation: + lb := b.Location.(common.AddressLocation) + addrA, addrB := la.Address.String(), lb.Address.String() + if addrA != addrB { + return addrA < addrB + } + return importName(a) < importName(b) + case common.StringLocation: + lb := b.Location.(common.StringLocation) + return string(la) < string(lb) + } + + return false +} + +// importName returns the primary identifier name for an import. +func importName(imp *ast.ImportDeclaration) string { + if len(imp.Imports) > 0 { + return imp.Imports[0].Identifier.Identifier + } + switch l := imp.Location.(type) { + case common.IdentifierLocation: + return string(l) + case common.AddressLocation: + return l.Name + case common.StringLocation: + return string(l) + } + return "" +} diff --git a/formatter/rewrite/rewrite.go b/formatter/rewrite/rewrite.go new file mode 100644 index 000000000..6de7b494d --- /dev/null +++ b/formatter/rewrite/rewrite.go @@ -0,0 +1,49 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rewrite + +import ( + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/formatter/trivia" +) + +// Rewriter transforms an AST program in place. Rewriters run in a fixed +// order; changing the order may break idempotence. +type Rewriter interface { + Name() string + Rewrite(prog *ast.Program, cm *trivia.CommentMap) error +} + +// Apply runs all rewriters in the canonical fixed order. +// If you change the pass order or add/remove passes, +// bump format.CurrentFormatVersion in options.go. +func Apply(prog *ast.Program, cm *trivia.CommentMap, sortImports bool) error { + var rewriters []Rewriter + if sortImports { + rewriters = append(rewriters, &importsSorter{}) + } + // modifiers: canonical ordering is enforced by the parser, so no rewrite needed + // parens: conservative removal deferred to later phase + for _, rw := range rewriters { + if err := rw.Rewrite(prog, cm); err != nil { + return err + } + } + return nil +} diff --git a/formatter/testdata/format/assignment-funcall-value/golden.cdc b/formatter/testdata/format/assignment-funcall-value/golden.cdc new file mode 100644 index 000000000..e23d8836d --- /dev/null +++ b/formatter/testdata/format/assignment-funcall-value/golden.cdc @@ -0,0 +1,10 @@ +access(all) fun test() { + var entries: [Entry] = [] + entries[0] = Entry( + keyIndex: 0, + publicKey: key, + hashAlgorithm: algo, + weight: 1.0, + isRevoked: true + ) +} diff --git a/formatter/testdata/format/assignment-funcall-value/input.cdc b/formatter/testdata/format/assignment-funcall-value/input.cdc new file mode 100644 index 000000000..839a32787 --- /dev/null +++ b/formatter/testdata/format/assignment-funcall-value/input.cdc @@ -0,0 +1,4 @@ +access(all) fun test() { + var entries: [Entry] = [] + entries[0] = Entry(keyIndex: 0, publicKey: key, hashAlgorithm: algo, weight: 1.0, isRevoked: true) +} diff --git a/formatter/testdata/format/binary-expr-continuation/golden.cdc b/formatter/testdata/format/binary-expr-continuation/golden.cdc new file mode 100644 index 000000000..745e4df5b --- /dev/null +++ b/formatter/testdata/format/binary-expr-continuation/golden.cdc @@ -0,0 +1,6 @@ +access(all) fun test() { + while index < arrayLength && RandomBeaconHistory.randomSourceHistory[index].length > 0 { + index = index + 1 + } + let short = a && b +} diff --git a/formatter/testdata/format/binary-expr-continuation/input.cdc b/formatter/testdata/format/binary-expr-continuation/input.cdc new file mode 100644 index 000000000..745e4df5b --- /dev/null +++ b/formatter/testdata/format/binary-expr-continuation/input.cdc @@ -0,0 +1,6 @@ +access(all) fun test() { + while index < arrayLength && RandomBeaconHistory.randomSourceHistory[index].length > 0 { + index = index + 1 + } + let short = a && b +} diff --git a/formatter/testdata/format/casting-continuation/golden.cdc b/formatter/testdata/format/casting-continuation/golden.cdc new file mode 100644 index 000000000..09b553a50 --- /dev/null +++ b/formatter/testdata/format/casting-continuation/golden.cdc @@ -0,0 +1,5 @@ +access(all) fun test() { + self.vault <- FlowToken.createEmptyVault(vaultType: Type<@FlowToken.Vault>()) + as! @FlowToken.Vault + let x = something as? @SomeType +} diff --git a/formatter/testdata/format/casting-continuation/input.cdc b/formatter/testdata/format/casting-continuation/input.cdc new file mode 100644 index 000000000..93b3dfc57 --- /dev/null +++ b/formatter/testdata/format/casting-continuation/input.cdc @@ -0,0 +1,4 @@ +access(all) fun test() { + self.vault <- FlowToken.createEmptyVault(vaultType: Type<@FlowToken.Vault>()) as! @FlowToken.Vault + let x = something as? @SomeType +} diff --git a/formatter/testdata/format/comment-between-decls/golden.cdc b/formatter/testdata/format/comment-between-decls/golden.cdc new file mode 100644 index 000000000..0908c46e7 --- /dev/null +++ b/formatter/testdata/format/comment-between-decls/golden.cdc @@ -0,0 +1,4 @@ +access(all) fun first() {} + +// This belongs to second +access(all) fun second() {} diff --git a/formatter/testdata/format/comment-between-decls/input.cdc b/formatter/testdata/format/comment-between-decls/input.cdc new file mode 100644 index 000000000..0908c46e7 --- /dev/null +++ b/formatter/testdata/format/comment-between-decls/input.cdc @@ -0,0 +1,4 @@ +access(all) fun first() {} + +// This belongs to second +access(all) fun second() {} diff --git a/formatter/testdata/format/comment-block-let-value/golden.cdc b/formatter/testdata/format/comment-block-let-value/golden.cdc new file mode 100644 index 000000000..3b8d8fe12 --- /dev/null +++ b/formatter/testdata/format/comment-block-let-value/golden.cdc @@ -0,0 +1,2 @@ +let A: A = /**/ +0 diff --git a/formatter/testdata/format/comment-block-let-value/input.cdc b/formatter/testdata/format/comment-block-let-value/input.cdc new file mode 100644 index 000000000..3b8d8fe12 --- /dev/null +++ b/formatter/testdata/format/comment-block-let-value/input.cdc @@ -0,0 +1,2 @@ +let A: A = /**/ +0 diff --git a/formatter/testdata/format/comment-doc-line/golden.cdc b/formatter/testdata/format/comment-doc-line/golden.cdc new file mode 100644 index 000000000..5f586a4b9 --- /dev/null +++ b/formatter/testdata/format/comment-doc-line/golden.cdc @@ -0,0 +1,3 @@ +/// This is a documented function. +/// It does important things. +access(all) fun documented() {} diff --git a/formatter/testdata/format/comment-doc-line/input.cdc b/formatter/testdata/format/comment-doc-line/input.cdc new file mode 100644 index 000000000..5f586a4b9 --- /dev/null +++ b/formatter/testdata/format/comment-doc-line/input.cdc @@ -0,0 +1,3 @@ +/// This is a documented function. +/// It does important things. +access(all) fun documented() {} diff --git a/formatter/testdata/format/comment-header-footer/golden.cdc b/formatter/testdata/format/comment-header-footer/golden.cdc new file mode 100644 index 000000000..3de95e89d --- /dev/null +++ b/formatter/testdata/format/comment-header-footer/golden.cdc @@ -0,0 +1,6 @@ +// Copyright 2024 +// All rights reserved + +access(all) fun main() {} + +// end of file diff --git a/formatter/testdata/format/comment-header-footer/input.cdc b/formatter/testdata/format/comment-header-footer/input.cdc new file mode 100644 index 000000000..3de95e89d --- /dev/null +++ b/formatter/testdata/format/comment-header-footer/input.cdc @@ -0,0 +1,6 @@ +// Copyright 2024 +// All rights reserved + +access(all) fun main() {} + +// end of file diff --git a/formatter/testdata/format/comment-inside-empty-pragma-parens/golden.cdc b/formatter/testdata/format/comment-inside-empty-pragma-parens/golden.cdc new file mode 100644 index 000000000..1ffac8351 --- /dev/null +++ b/formatter/testdata/format/comment-inside-empty-pragma-parens/golden.cdc @@ -0,0 +1,2 @@ +#() +// pragma comment diff --git a/formatter/testdata/format/comment-inside-empty-pragma-parens/input.cdc b/formatter/testdata/format/comment-inside-empty-pragma-parens/input.cdc new file mode 100644 index 000000000..d5e1ee307 --- /dev/null +++ b/formatter/testdata/format/comment-inside-empty-pragma-parens/input.cdc @@ -0,0 +1,2 @@ +#(// pragma comment +) diff --git a/formatter/testdata/format/comment-inside-invocation/golden.cdc b/formatter/testdata/format/comment-inside-invocation/golden.cdc new file mode 100644 index 000000000..609fd2fc7 --- /dev/null +++ b/formatter/testdata/format/comment-inside-invocation/golden.cdc @@ -0,0 +1,18 @@ +access(all) struct VerifyResult { + access(all) let canExecute: Bool + + access(all) let requiredBalance: UFix64 + + init(canExecute: Bool, requiredBalance: UFix64) { + self.canExecute = canExecute + self.requiredBalance = requiredBalance + } +} + +access(all) fun verify(balance: UFix64): VerifyResult { + return VerifyResult( + // The transaction can be executed if the balance is sufficient. + canExecute: balance >= 10.0, + requiredBalance: 10.0 + ) +} diff --git a/formatter/testdata/format/comment-inside-invocation/input.cdc b/formatter/testdata/format/comment-inside-invocation/input.cdc new file mode 100644 index 000000000..6677047b1 --- /dev/null +++ b/formatter/testdata/format/comment-inside-invocation/input.cdc @@ -0,0 +1,17 @@ +access(all) struct VerifyResult { + access(all) let canExecute: Bool + access(all) let requiredBalance: UFix64 + + init(canExecute: Bool, requiredBalance: UFix64) { + self.canExecute = canExecute + self.requiredBalance = requiredBalance + } +} + +access(all) fun verify(balance: UFix64): VerifyResult { + return VerifyResult( + // The transaction can be executed if the balance is sufficient. + canExecute: balance >= 10.0, + requiredBalance: 10.0 + ) +} diff --git a/formatter/testdata/format/comment-leading-let-value/golden.cdc b/formatter/testdata/format/comment-leading-let-value/golden.cdc new file mode 100644 index 000000000..d92addf9e --- /dev/null +++ b/formatter/testdata/format/comment-leading-let-value/golden.cdc @@ -0,0 +1,3 @@ +let A: A = +// value comment +0 diff --git a/formatter/testdata/format/comment-leading-let-value/input.cdc b/formatter/testdata/format/comment-leading-let-value/input.cdc new file mode 100644 index 000000000..45f57431b --- /dev/null +++ b/formatter/testdata/format/comment-leading-let-value/input.cdc @@ -0,0 +1,4 @@ +let A: A = + +// value comment +0 diff --git a/formatter/testdata/format/comment-leading/golden.cdc b/formatter/testdata/format/comment-leading/golden.cdc new file mode 100644 index 000000000..a2c9421d0 --- /dev/null +++ b/formatter/testdata/format/comment-leading/golden.cdc @@ -0,0 +1,2 @@ +// this function does things +access(all) fun doThings() {} diff --git a/formatter/testdata/format/comment-leading/input.cdc b/formatter/testdata/format/comment-leading/input.cdc new file mode 100644 index 000000000..a2c9421d0 --- /dev/null +++ b/formatter/testdata/format/comment-leading/input.cdc @@ -0,0 +1,2 @@ +// this function does things +access(all) fun doThings() {} diff --git a/formatter/testdata/format/comment-sameline-invocation/golden.cdc b/formatter/testdata/format/comment-sameline-invocation/golden.cdc new file mode 100644 index 000000000..f653e2886 --- /dev/null +++ b/formatter/testdata/format/comment-sameline-invocation/golden.cdc @@ -0,0 +1,10 @@ +access(all) fun test() { + let x = Metadata( + counter: epochCounter, + seed: randomSource, + totalRewards: 0.0, // will be overwritten in calculateRewards + clusters: [], + keys: [] + ) + process(x) +} diff --git a/formatter/testdata/format/comment-sameline-invocation/input.cdc b/formatter/testdata/format/comment-sameline-invocation/input.cdc new file mode 100644 index 000000000..83052d01d --- /dev/null +++ b/formatter/testdata/format/comment-sameline-invocation/input.cdc @@ -0,0 +1,10 @@ +access(all) fun test() { + let x = Metadata( + counter: epochCounter, + seed: randomSource, + totalRewards: 0.0, // will be overwritten in calculateRewards + clusters: [], + keys: [] + ) + process(x) +} diff --git a/formatter/testdata/format/comment-sameline/golden.cdc b/formatter/testdata/format/comment-sameline/golden.cdc new file mode 100644 index 000000000..72b542760 --- /dev/null +++ b/formatter/testdata/format/comment-sameline/golden.cdc @@ -0,0 +1,3 @@ +let x = 1 // the value of x + +let y = 2 // the value of y diff --git a/formatter/testdata/format/comment-sameline/input.cdc b/formatter/testdata/format/comment-sameline/input.cdc new file mode 100644 index 000000000..53e498178 --- /dev/null +++ b/formatter/testdata/format/comment-sameline/input.cdc @@ -0,0 +1,2 @@ +let x = 1 // the value of x +let y = 2 // the value of y diff --git a/formatter/testdata/format/comment-trailing-typeann/golden.cdc b/formatter/testdata/format/comment-trailing-typeann/golden.cdc new file mode 100644 index 000000000..0cfaf951a --- /dev/null +++ b/formatter/testdata/format/comment-trailing-typeann/golden.cdc @@ -0,0 +1,3 @@ +let A: A = +// +0 diff --git a/formatter/testdata/format/comment-trailing-typeann/input.cdc b/formatter/testdata/format/comment-trailing-typeann/input.cdc new file mode 100644 index 000000000..dc66cf24a --- /dev/null +++ b/formatter/testdata/format/comment-trailing-typeann/input.cdc @@ -0,0 +1,2 @@ +let A: A = // +0 diff --git a/formatter/testdata/format/comment-trailing/golden.cdc b/formatter/testdata/format/comment-trailing/golden.cdc new file mode 100644 index 000000000..c5177fa19 --- /dev/null +++ b/formatter/testdata/format/comment-trailing/golden.cdc @@ -0,0 +1,4 @@ +access(all) fun a() {} +// after a + +access(all) fun b() {} diff --git a/formatter/testdata/format/comment-trailing/input.cdc b/formatter/testdata/format/comment-trailing/input.cdc new file mode 100644 index 000000000..1d7679b41 --- /dev/null +++ b/formatter/testdata/format/comment-trailing/input.cdc @@ -0,0 +1,3 @@ +access(all) fun a() {} +// after a +access(all) fun b() {} diff --git a/formatter/testdata/format/entitlement/golden.cdc b/formatter/testdata/format/entitlement/golden.cdc new file mode 100644 index 000000000..6586a9803 --- /dev/null +++ b/formatter/testdata/format/entitlement/golden.cdc @@ -0,0 +1,7 @@ +access(all) contract Foo { + access(all) entitlement NodeOperator + + access(all) entitlement mapping AccountMapping { + include Identity + } +} diff --git a/formatter/testdata/format/entitlement/input.cdc b/formatter/testdata/format/entitlement/input.cdc new file mode 100644 index 000000000..91578b073 --- /dev/null +++ b/formatter/testdata/format/entitlement/input.cdc @@ -0,0 +1,10 @@ +access(all) contract Foo { + + access(all) + entitlement NodeOperator + + access(all) + entitlement mapping AccountMapping { + include Identity + } +} diff --git a/formatter/testdata/format/event-param-comments/golden.cdc b/formatter/testdata/format/event-param-comments/golden.cdc new file mode 100644 index 000000000..2d20af702 --- /dev/null +++ b/formatter/testdata/format/event-param-comments/golden.cdc @@ -0,0 +1,12 @@ +access(all) contract C { + access(all) event Transfer( + /// The sender address + from: Address, + /// The receiver address + to: Address, + /// The amount transferred + amount: UFix64 + ) + + access(all) event Simple(a: Int, b: String) +} diff --git a/formatter/testdata/format/event-param-comments/input.cdc b/formatter/testdata/format/event-param-comments/input.cdc new file mode 100644 index 000000000..2d20af702 --- /dev/null +++ b/formatter/testdata/format/event-param-comments/input.cdc @@ -0,0 +1,12 @@ +access(all) contract C { + access(all) event Transfer( + /// The sender address + from: Address, + /// The receiver address + to: Address, + /// The amount transferred + amount: UFix64 + ) + + access(all) event Simple(a: Int, b: String) +} diff --git a/formatter/testdata/format/for-loop-comments/golden.cdc b/formatter/testdata/format/for-loop-comments/golden.cdc new file mode 100644 index 000000000..a29cc164b --- /dev/null +++ b/formatter/testdata/format/for-loop-comments/golden.cdc @@ -0,0 +1,10 @@ +access(all) fun test(items: [Int]) { + for item in items { + // Check validity + if item < 0 { + continue + } + // Process item + let x = item + } +} diff --git a/formatter/testdata/format/for-loop-comments/input.cdc b/formatter/testdata/format/for-loop-comments/input.cdc new file mode 100644 index 000000000..a29cc164b --- /dev/null +++ b/formatter/testdata/format/for-loop-comments/input.cdc @@ -0,0 +1,10 @@ +access(all) fun test(items: [Int]) { + for item in items { + // Check validity + if item < 0 { + continue + } + // Process item + let x = item + } +} diff --git a/formatter/testdata/format/function-with-params/golden.cdc b/formatter/testdata/format/function-with-params/golden.cdc new file mode 100644 index 000000000..c9fada76d --- /dev/null +++ b/formatter/testdata/format/function-with-params/golden.cdc @@ -0,0 +1 @@ +access(all) fun transfer(amount: UFix64, to: Address, memo: String) {} diff --git a/formatter/testdata/format/function-with-params/input.cdc b/formatter/testdata/format/function-with-params/input.cdc new file mode 100644 index 000000000..e3c21a562 --- /dev/null +++ b/formatter/testdata/format/function-with-params/input.cdc @@ -0,0 +1 @@ +access(all) fun transfer( amount : UFix64 , to : Address , memo:String ) { } diff --git a/formatter/testdata/format/hello-world/golden.cdc b/formatter/testdata/format/hello-world/golden.cdc new file mode 100644 index 000000000..74eb5cdcf --- /dev/null +++ b/formatter/testdata/format/hello-world/golden.cdc @@ -0,0 +1 @@ +access(all) fun main() {} diff --git a/formatter/testdata/format/hello-world/input.cdc b/formatter/testdata/format/hello-world/input.cdc new file mode 100644 index 000000000..631f1b1a8 --- /dev/null +++ b/formatter/testdata/format/hello-world/input.cdc @@ -0,0 +1 @@ +access(all) fun main() { } diff --git a/formatter/testdata/format/if-long-condition/golden.cdc b/formatter/testdata/format/if-long-condition/golden.cdc new file mode 100644 index 000000000..f428341ae --- /dev/null +++ b/formatter/testdata/format/if-long-condition/golden.cdc @@ -0,0 +1,10 @@ +access(all) fun test() { + if !key.publicKey.verify( + signature: signature.signature, + signedData: signedData, + domainSeparationTag: domainSeparationTag, + hashAlgorithm: key.hashAlgorithm + ) { + return false + } +} diff --git a/formatter/testdata/format/if-long-condition/input.cdc b/formatter/testdata/format/if-long-condition/input.cdc new file mode 100644 index 000000000..15e524291 --- /dev/null +++ b/formatter/testdata/format/if-long-condition/input.cdc @@ -0,0 +1,5 @@ +access(all) fun test() { + if !key.publicKey.verify(signature: signature.signature, signedData: signedData, domainSeparationTag: domainSeparationTag, hashAlgorithm: key.hashAlgorithm) { + return false + } +} diff --git a/formatter/testdata/format/imports-already-sorted/golden.cdc b/formatter/testdata/format/imports-already-sorted/golden.cdc new file mode 100644 index 000000000..7f52af87d --- /dev/null +++ b/formatter/testdata/format/imports-already-sorted/golden.cdc @@ -0,0 +1,8 @@ +import Crypto + +import NonFungibleToken from 0x631e88ae7f1d7c20 +import FungibleToken from 0x9a0766d93b6608b7 + +import "MyContract" + +access(all) fun main() {} diff --git a/formatter/testdata/format/imports-already-sorted/input.cdc b/formatter/testdata/format/imports-already-sorted/input.cdc new file mode 100644 index 000000000..c8c5aa0d4 --- /dev/null +++ b/formatter/testdata/format/imports-already-sorted/input.cdc @@ -0,0 +1,8 @@ +import Crypto + +import FungibleToken from 0x9a0766d93b6608b7 +import NonFungibleToken from 0x631e88ae7f1d7c20 + +import "MyContract" + +access(all) fun main() {} diff --git a/formatter/testdata/format/imports-sorting/golden.cdc b/formatter/testdata/format/imports-sorting/golden.cdc new file mode 100644 index 000000000..62431d3bb --- /dev/null +++ b/formatter/testdata/format/imports-sorting/golden.cdc @@ -0,0 +1,9 @@ +import Crypto + +import NonFungibleToken from 0x631e88ae7f1d7c20 +import FungibleToken from 0x9a0766d93b6608b7 + +import "AnotherContract" +import "MyContract" + +access(all) fun main() {} diff --git a/formatter/testdata/format/imports-sorting/input.cdc b/formatter/testdata/format/imports-sorting/input.cdc new file mode 100644 index 000000000..96bd1252d --- /dev/null +++ b/formatter/testdata/format/imports-sorting/input.cdc @@ -0,0 +1,7 @@ +import "MyContract" +import NonFungibleToken from 0x631e88ae7f1d7c20 +import Crypto +import FungibleToken from 0x9a0766d93b6608b7 +import "AnotherContract" + +access(all) fun main() {} diff --git a/formatter/testdata/format/imports/golden.cdc b/formatter/testdata/format/imports/golden.cdc new file mode 100644 index 000000000..02efa3f75 --- /dev/null +++ b/formatter/testdata/format/imports/golden.cdc @@ -0,0 +1,4 @@ +import NonFungibleToken from 0x631e88ae7f1d7c20 +import FungibleToken from 0x9a0766d93b6608b7 + +access(all) fun main() {} diff --git a/formatter/testdata/format/imports/input.cdc b/formatter/testdata/format/imports/input.cdc new file mode 100644 index 000000000..fa6829a0e --- /dev/null +++ b/formatter/testdata/format/imports/input.cdc @@ -0,0 +1,4 @@ +import FungibleToken from 0x9a0766d93b6608b7 +import NonFungibleToken from 0x631e88ae7f1d7c20 + +access(all) fun main() {} diff --git a/formatter/testdata/format/keep-blank-lines-body/golden.cdc b/formatter/testdata/format/keep-blank-lines-body/golden.cdc new file mode 100644 index 000000000..ed2e665be --- /dev/null +++ b/formatter/testdata/format/keep-blank-lines-body/golden.cdc @@ -0,0 +1,18 @@ +access(all) contract Test { + init() { + self.a = 1 + self.b = 2 + + self.c = 3 + + self.d = 4 + self.e = 5 + } + + access(all) fun example() { + let x = 1 + + let y = 2 + let z = 3 + } +} diff --git a/formatter/testdata/format/keep-blank-lines-body/input.cdc b/formatter/testdata/format/keep-blank-lines-body/input.cdc new file mode 100644 index 000000000..ed2e665be --- /dev/null +++ b/formatter/testdata/format/keep-blank-lines-body/input.cdc @@ -0,0 +1,18 @@ +access(all) contract Test { + init() { + self.a = 1 + self.b = 2 + + self.c = 3 + + self.d = 4 + self.e = 5 + } + + access(all) fun example() { + let x = 1 + + let y = 2 + let z = 3 + } +} diff --git a/formatter/testdata/format/keep-blank-lines/golden.cdc b/formatter/testdata/format/keep-blank-lines/golden.cdc new file mode 100644 index 000000000..6c5462c58 --- /dev/null +++ b/formatter/testdata/format/keep-blank-lines/golden.cdc @@ -0,0 +1,5 @@ +access(all) fun first() {} + +access(all) fun second() {} + +access(all) fun third() {} diff --git a/formatter/testdata/format/keep-blank-lines/input.cdc b/formatter/testdata/format/keep-blank-lines/input.cdc new file mode 100644 index 000000000..b2dc3f190 --- /dev/null +++ b/formatter/testdata/format/keep-blank-lines/input.cdc @@ -0,0 +1,10 @@ +access(all) fun first() {} + + + +access(all) fun second() {} + + + + +access(all) fun third() {} diff --git a/formatter/testdata/format/return-nil-coalescing/golden.cdc b/formatter/testdata/format/return-nil-coalescing/golden.cdc new file mode 100644 index 000000000..a0ce4ebcc --- /dev/null +++ b/formatter/testdata/format/return-nil-coalescing/golden.cdc @@ -0,0 +1,6 @@ +access(all) contract FlowExecutionParameters { + access(all) view fun getExecutionEffortWeights(): {UInt64: UInt64} { + return self.account.storage.copy<{UInt64: UInt64}>(from: /storage/executionEffortWeights) + ?? panic("execution effort weights not set yet") + } +} diff --git a/formatter/testdata/format/return-nil-coalescing/input.cdc b/formatter/testdata/format/return-nil-coalescing/input.cdc new file mode 100644 index 000000000..7cb2f083a --- /dev/null +++ b/formatter/testdata/format/return-nil-coalescing/input.cdc @@ -0,0 +1,6 @@ +access(all) contract FlowExecutionParameters { + access(all) view fun getExecutionEffortWeights(): {UInt64: UInt64} { + return self.account.storage.copy<{UInt64: UInt64}>(from: /storage/executionEffortWeights) + ?? panic("execution effort weights not set yet") + } +} diff --git a/formatter/testdata/format/return-simple/golden.cdc b/formatter/testdata/format/return-simple/golden.cdc new file mode 100644 index 000000000..e51e7f211 --- /dev/null +++ b/formatter/testdata/format/return-simple/golden.cdc @@ -0,0 +1,3 @@ +access(all) fun getBalance(): UFix64 { + return self.balance +} diff --git a/formatter/testdata/format/return-simple/input.cdc b/formatter/testdata/format/return-simple/input.cdc new file mode 100644 index 000000000..e1d0fec46 --- /dev/null +++ b/formatter/testdata/format/return-simple/input.cdc @@ -0,0 +1,3 @@ +access(all) fun getBalance() : UFix64 { + return self.balance +} diff --git a/formatter/testdata/format/semicolons/golden.cdc b/formatter/testdata/format/semicolons/golden.cdc new file mode 100644 index 000000000..8255f3cbf --- /dev/null +++ b/formatter/testdata/format/semicolons/golden.cdc @@ -0,0 +1,6 @@ +access(all) let x: Int = 1 + +access(all) fun main() { + let y = 2 + log(y) +} diff --git a/formatter/testdata/format/semicolons/input.cdc b/formatter/testdata/format/semicolons/input.cdc new file mode 100644 index 000000000..57f3858d6 --- /dev/null +++ b/formatter/testdata/format/semicolons/input.cdc @@ -0,0 +1,6 @@ +access(all) let x: Int = 1; + +access(all) fun main() { + let y = 2; + log(y); +} diff --git a/formatter/testdata/format/simple-resource/golden.cdc b/formatter/testdata/format/simple-resource/golden.cdc new file mode 100644 index 000000000..26594cfa4 --- /dev/null +++ b/formatter/testdata/format/simple-resource/golden.cdc @@ -0,0 +1,11 @@ +access(all) resource Vault { + access(all) var balance: UFix64 + + init(balance: UFix64) { + self.balance = balance + } + + access(all) fun getBalance(): UFix64 { + return self.balance + } +} diff --git a/formatter/testdata/format/simple-resource/input.cdc b/formatter/testdata/format/simple-resource/input.cdc new file mode 100644 index 000000000..76b8cbbbb --- /dev/null +++ b/formatter/testdata/format/simple-resource/input.cdc @@ -0,0 +1,11 @@ +access(all) resource Vault { + access(all) var balance: UFix64 + + init( balance : UFix64 ) { + self.balance = balance + } + + access(all) fun getBalance() : UFix64 { + return self.balance + } +} diff --git a/formatter/testdata/format/string-interpolation/golden.cdc b/formatter/testdata/format/string-interpolation/golden.cdc new file mode 100644 index 000000000..88adbbec5 --- /dev/null +++ b/formatter/testdata/format/string-interpolation/golden.cdc @@ -0,0 +1,6 @@ +access(all) fun test() { + panic( + "Cannot borrow DKG Participant reference from path \(FlowDKG.ParticipantStoragePath). The signer needs to ensure their account is initialized with the DKG Participant resource." + ) + let short = "Hello \(name)!" +} diff --git a/formatter/testdata/format/string-interpolation/input.cdc b/formatter/testdata/format/string-interpolation/input.cdc new file mode 100644 index 000000000..c932ded40 --- /dev/null +++ b/formatter/testdata/format/string-interpolation/input.cdc @@ -0,0 +1,4 @@ +access(all) fun test() { + panic("Cannot borrow DKG Participant reference from path \(FlowDKG.ParticipantStoragePath). The signer needs to ensure their account is initialized with the DKG Participant resource.") + let short = "Hello \(name)!" +} diff --git a/formatter/testdata/format/transaction-comments/golden.cdc b/formatter/testdata/format/transaction-comments/golden.cdc new file mode 100644 index 000000000..09de10e33 --- /dev/null +++ b/formatter/testdata/format/transaction-comments/golden.cdc @@ -0,0 +1,10 @@ +transaction(name: String, code: String) { + prepare(account: auth(UpdateContract) &Account) { + // Upgrade the contract + account.contracts.update(name: name, code: code.utf8) + } + execute { + // Log the result + log("done") + } +} diff --git a/formatter/testdata/format/transaction-comments/input.cdc b/formatter/testdata/format/transaction-comments/input.cdc new file mode 100644 index 000000000..b5afe19a1 --- /dev/null +++ b/formatter/testdata/format/transaction-comments/input.cdc @@ -0,0 +1,11 @@ +transaction(name: String, code: String) { + prepare(account: auth(UpdateContract) &Account) { + // Upgrade the contract + account.contracts.update(name: name, code: code.utf8) + } + + execute { + // Log the result + log("done") + } +} diff --git a/formatter/testdata/format/variable-declarations/golden.cdc b/formatter/testdata/format/variable-declarations/golden.cdc new file mode 100644 index 000000000..37cb63425 --- /dev/null +++ b/formatter/testdata/format/variable-declarations/golden.cdc @@ -0,0 +1,5 @@ +let x: Int = 42 + +var name: String = "hello" + +let flag: Bool = true diff --git a/formatter/testdata/format/variable-declarations/input.cdc b/formatter/testdata/format/variable-declarations/input.cdc new file mode 100644 index 000000000..0f614d954 --- /dev/null +++ b/formatter/testdata/format/variable-declarations/input.cdc @@ -0,0 +1,3 @@ +let x : Int = 42 +var name:String = "hello" +let flag :Bool=true diff --git a/formatter/testdata/format/variable-funcall-value/golden.cdc b/formatter/testdata/format/variable-funcall-value/golden.cdc new file mode 100644 index 000000000..f70d4cf2d --- /dev/null +++ b/formatter/testdata/format/variable-funcall-value/golden.cdc @@ -0,0 +1,9 @@ +access(all) fun test() { + let entry = KeyListEntry( + keyIndex: keyIndex, + publicKey: publicKey, + hashAlgorithm: hashAlgorithm, + weight: weight, + isRevoked: false + ) +} diff --git a/formatter/testdata/format/variable-funcall-value/input.cdc b/formatter/testdata/format/variable-funcall-value/input.cdc new file mode 100644 index 000000000..f70d4cf2d --- /dev/null +++ b/formatter/testdata/format/variable-funcall-value/input.cdc @@ -0,0 +1,9 @@ +access(all) fun test() { + let entry = KeyListEntry( + keyIndex: keyIndex, + publicKey: publicKey, + hashAlgorithm: hashAlgorithm, + weight: weight, + isRevoked: false + ) +} diff --git a/formatter/testdata/format/variable-nil-coalescing/golden.cdc b/formatter/testdata/format/variable-nil-coalescing/golden.cdc new file mode 100644 index 000000000..a4c0a3b4b --- /dev/null +++ b/formatter/testdata/format/variable-nil-coalescing/golden.cdc @@ -0,0 +1,5 @@ +access(all) fun test() { + let weights: {UInt64: UInt64} = + self.account.storage.copy<{UInt64: UInt64}>(from: /storage/executionEffortWeights) + ?? panic("weights not set") +} diff --git a/formatter/testdata/format/variable-nil-coalescing/input.cdc b/formatter/testdata/format/variable-nil-coalescing/input.cdc new file mode 100644 index 000000000..b70e0a9cb --- /dev/null +++ b/formatter/testdata/format/variable-nil-coalescing/input.cdc @@ -0,0 +1,4 @@ +access(all) fun test() { + let weights: {UInt64: UInt64} = self.account.storage.copy<{UInt64: UInt64}>(from: /storage/executionEffortWeights) + ?? panic("weights not set") +} diff --git a/formatter/testdata/fuzz/FuzzFormat/b51a31c9df9442e4 b/formatter/testdata/fuzz/FuzzFormat/b51a31c9df9442e4 new file mode 100644 index 000000000..fa648f3cd --- /dev/null +++ b/formatter/testdata/fuzz/FuzzFormat/b51a31c9df9442e4 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("contract A{event A(//\n)}") diff --git a/formatter/testdata/fuzz/FuzzRoundtrip/0f1d42ffe564c745 b/formatter/testdata/fuzz/FuzzRoundtrip/0f1d42ffe564c745 new file mode 100644 index 000000000..8008b0935 --- /dev/null +++ b/formatter/testdata/fuzz/FuzzRoundtrip/0f1d42ffe564c745 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("let A:A=\n\n//\n0") diff --git a/formatter/testdata/fuzz/FuzzRoundtrip/59ed9ab21a41e477 b/formatter/testdata/fuzz/FuzzRoundtrip/59ed9ab21a41e477 new file mode 100644 index 000000000..f0b532948 --- /dev/null +++ b/formatter/testdata/fuzz/FuzzRoundtrip/59ed9ab21a41e477 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("let A:A=\n\n/**/0") diff --git a/formatter/testdata/fuzz/FuzzRoundtrip/b55e4b1d068b21cf b/formatter/testdata/fuzz/FuzzRoundtrip/b55e4b1d068b21cf new file mode 100644 index 000000000..5aa79bc69 --- /dev/null +++ b/formatter/testdata/fuzz/FuzzRoundtrip/b55e4b1d068b21cf @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("let A:A=//\n//0\n0%0") diff --git a/formatter/testdata/fuzz/FuzzRoundtrip/bb3614ccfa60c9d6 b/formatter/testdata/fuzz/FuzzRoundtrip/bb3614ccfa60c9d6 new file mode 100644 index 000000000..3b56fead8 --- /dev/null +++ b/formatter/testdata/fuzz/FuzzRoundtrip/bb3614ccfa60c9d6 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("event A(//\n)") diff --git a/formatter/testdata/fuzz/FuzzRoundtrip/cc5f86b8ce6f3bb3 b/formatter/testdata/fuzz/FuzzRoundtrip/cc5f86b8ce6f3bb3 new file mode 100644 index 000000000..5ed6defc5 --- /dev/null +++ b/formatter/testdata/fuzz/FuzzRoundtrip/cc5f86b8ce6f3bb3 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("#//\n0//\n#0") diff --git a/formatter/testdata/fuzz/FuzzRoundtrip/f1317c40fc90d7b9 b/formatter/testdata/fuzz/FuzzRoundtrip/f1317c40fc90d7b9 new file mode 100644 index 000000000..35094a234 --- /dev/null +++ b/formatter/testdata/fuzz/FuzzRoundtrip/f1317c40fc90d7b9 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("struct A{access(A)A:A#(//\n)}") diff --git a/formatter/testutil_test.go b/formatter/testutil_test.go new file mode 100644 index 000000000..b9e94128f --- /dev/null +++ b/formatter/testutil_test.go @@ -0,0 +1,45 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package formatter_test + +import ( + "os" + "path/filepath" + "testing" +) + +// findRepoRoot walks up from the working directory to find the repo root +// (identified by go.mod). Works with both *testing.T and *testing.F. +func findRepoRoot(tb testing.TB) string { + tb.Helper() + dir, err := os.Getwd() + if err != nil { + tb.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + tb.Fatal("could not find repo root (go.mod)") + } + dir = parent + } +} diff --git a/formatter/trivia/attach.go b/formatter/trivia/attach.go new file mode 100644 index 000000000..352d15995 --- /dev/null +++ b/formatter/trivia/attach.go @@ -0,0 +1,377 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package trivia + +import ( + "fmt" + "sort" + + "github.com/onflow/cadence/ast" +) + +// CommentMap binds comment groups to AST nodes by position class. +// Take() removes and returns comments for a node — this ensures each +// comment is emitted exactly once during rendering. After rendering, +// the map should be empty; any leftovers indicate a bug. +type CommentMap struct { + Header []*CommentGroup // before first declaration + Footer []*CommentGroup // after last declaration + Leading map[ast.Element][]*CommentGroup + Trailing map[ast.Element][]*CommentGroup + SameLine map[ast.Element]*CommentGroup // at most one per node +} + +// NewCommentMap creates an empty CommentMap with initialized maps. +func NewCommentMap() *CommentMap { + return &CommentMap{ + Leading: make(map[ast.Element][]*CommentGroup), + Trailing: make(map[ast.Element][]*CommentGroup), + SameLine: make(map[ast.Element]*CommentGroup), + } +} + +// Take removes and returns all comments associated with n. +func (cm *CommentMap) Take(n ast.Element) (leading []*CommentGroup, sameLine *CommentGroup, trailing []*CommentGroup) { + leading = cm.Leading[n] + delete(cm.Leading, n) + sameLine = cm.SameLine[n] + delete(cm.SameLine, n) + trailing = cm.Trailing[n] + delete(cm.Trailing, n) + return +} + +// TakeHeader removes and returns header comments. +func (cm *CommentMap) TakeHeader() []*CommentGroup { + h := cm.Header + cm.Header = nil + return h +} + +// TakeFooter removes and returns footer comments. +func (cm *CommentMap) TakeFooter() []*CommentGroup { + f := cm.Footer + cm.Footer = nil + return f +} + +// IsEmpty returns true if no comments remain in the map. +func (cm *CommentMap) IsEmpty() bool { + return len(cm.Header) == 0 && + len(cm.Footer) == 0 && + len(cm.Leading) == 0 && + len(cm.Trailing) == 0 && + len(cm.SameLine) == 0 +} + +// OrphanDetails returns a human-readable summary of remaining comments in the map. +func (cm *CommentMap) OrphanDetails() string { + var details string + for k, v := range cm.Leading { //nolint:maprange + for _, g := range v { + details += fmt.Sprintf(" Leading on %T at %s: %q\n", k, k.StartPosition(), g.Comments[0].Text) + } + } + for k, v := range cm.Trailing { //nolint:maprange + for _, g := range v { + details += fmt.Sprintf(" Trailing on %T at %s: %q\n", k, k.StartPosition(), g.Comments[0].Text) + } + } + for k, v := range cm.SameLine { //nolint:maprange + details += fmt.Sprintf(" SameLine on %T at %s: %q\n", k, k.StartPosition(), v.Comments[0].Text) + } + return details +} + +// Attach walks the AST and binds comment groups to nodes by position. +func Attach(program *ast.Program, groups []*CommentGroup, source []byte) *CommentMap { + cm := NewCommentMap() + if len(groups) == 0 { + return cm + } + + decls := program.Declarations() + elements := make([]ast.Element, len(decls)) + for i, d := range decls { + elements[i] = d + } + + remaining := attachLevel(cm, elements, groups, true, source) + + // Anything left over is footer + cm.Footer = append(cm.Footer, remaining...) + return cm +} + +// attachLevel distributes comment groups among a sequence of sibling elements. +// It recurses into each element's children for groups that fall inside the element. +// Returns any groups not consumed (after the last sibling). +func attachLevel(cm *CommentMap, siblings []ast.Element, groups []*CommentGroup, isTopLevel bool, source []byte) []*CommentGroup { + if len(groups) == 0 { + return nil + } + + if len(siblings) == 0 { + if isTopLevel { + cm.Header = append(cm.Header, groups...) + return nil + } + return groups + } + + gi := 0 // index into groups + + // Groups before first sibling + firstStart := siblings[0].StartPosition() + for gi < len(groups) && groups[gi].EndPos().Offset < firstStart.Offset { + if isTopLevel { + // Check if this is the last group before the first decl + nextGi := gi + 1 + isLastBefore := nextGi >= len(groups) || groups[nextGi].EndPos().Offset >= firstStart.Offset + + if !isLastBefore || blankLineBetween(groups[gi].EndPos(), firstStart) { + cm.Header = append(cm.Header, groups[gi]) + } else { + cm.Leading[siblings[0]] = append(cm.Leading[siblings[0]], groups[gi]) + } + } else { + cm.Leading[siblings[0]] = append(cm.Leading[siblings[0]], groups[gi]) + } + gi++ + } + + // Process each sibling + for si := 0; si < len(siblings); si++ { + node := siblings[si] + nodeStart := node.StartPosition() + // nodeEndRaw is what the parser reports — used for the inside check + // because comments physically inside the un-clipped span belong to + // descendants of node. + nodeEndRaw := node.EndPosition(nil) + // nodeEnd is the syntactic end (last non-whitespace byte). Used for + // sameLine and between-sibling decisions because some upstream + // constructs report an end position past their closing token (e.g. + // VoidExpression `()` whose EndPos is the start of the *next* token, + // which on multi-line input lands on the next line's indent and pulls + // a following comment into a spurious sameLine attachment). + nodeEnd := trueEndPosition(nodeEndRaw, source) + + // Collect groups that fall inside this node (start after node start, end at or before node end) + var inside []*CommentGroup + for gi < len(groups) { + g := groups[gi] + gStart := g.StartPos() + gEnd := g.EndPos() + + if gStart.Offset > nodeStart.Offset && gEnd.Offset <= nodeEndRaw.Offset { + inside = append(inside, g) + gi++ + continue + } + break + } + + // Recursively handle inside groups + if len(inside) > 0 { + children := getChildren(node) + leftover := attachLevel(cm, children, inside, false, source) + // Leftover from inside = trailing of last child, or dangling + if len(leftover) > 0 { + if len(children) > 0 { + lastChild := children[len(children)-1] + cm.Trailing[lastChild] = append(cm.Trailing[lastChild], leftover...) + } else { + // Dangling: no children, attach as leading of this node + cm.Leading[node] = append(cm.Leading[node], leftover...) + } + } + } + + // Same-line comment: on same line as node end, after the node + if gi < len(groups) { + g := groups[gi] + if g.StartPos().Line == nodeEnd.Line && g.StartPos().Offset > nodeEnd.Offset { + // Make sure it's not inside the next sibling + isBeforeNext := si+1 >= len(siblings) || g.EndPos().Offset < siblings[si+1].StartPosition().Offset + if isBeforeNext { + cm.SameLine[node] = g + gi++ + } + } + } + + // Groups between this sibling and the next + if si+1 < len(siblings) { + nextStart := siblings[si+1].StartPosition() + + for gi < len(groups) && groups[gi].EndPos().Offset < nextStart.Offset { + g := groups[gi] + // Disambiguation heuristic for comments between siblings: + // 1. Same-line comments are handled above + // 2. Blank line between previous sibling end and comment → Leading of next + // 3. No blank line (adjacent) → Trailing of previous + if blankLineBetween(nodeEnd, g.StartPos()) { + cm.Leading[siblings[si+1]] = append(cm.Leading[siblings[si+1]], g) + } else { + cm.Trailing[node] = append(cm.Trailing[node], g) + } + gi++ + } + } + } + + // Groups after the last sibling: mirror the "between siblings" heuristic. + // Without this, comments on the next line after the last sibling are + // left unconsumed and incorrectly classified as footer/header by the caller. + if len(siblings) > 0 && gi < len(groups) { + lastNode := siblings[len(siblings)-1] + lastEnd := trueEndPosition(lastNode.EndPosition(nil), source) + if sl := cm.SameLine[lastNode]; sl != nil { + lastEnd = sl.EndPos() + } + for gi < len(groups) { + g := groups[gi] + if blankLineBetween(lastEnd, g.StartPos()) { + break + } + cm.Trailing[lastNode] = append(cm.Trailing[lastNode], g) + gi++ + lastEnd = g.EndPos() + } + } + + // Return unconsumed groups + return groups[gi:] +} + +// trueEndPosition returns the position of the last non-whitespace byte at or +// before reportedEnd.Offset. Compensates for upstream parser quirks where +// some node EndPositions land on the next token's whitespace — notably +// VoidExpression `()`, whose EndPos is set to p.current.EndPos AFTER consuming +// `)`, which on multi-line input is the start of the next line's indent. +// Without this, a comment on the line after such a node attaches as SameLine, +// which differs from how a re-parse classifies the same comment after the +// formatter has normalized the layout, breaking idempotence. +// +// Column is left as-is since attach.go only reads Line and Offset for its +// decisions. +func trueEndPosition(reportedEnd ast.Position, source []byte) ast.Position { + if len(source) == 0 { + return reportedEnd + } + offset := reportedEnd.Offset + if offset >= len(source) { + offset = len(source) - 1 + } + line := reportedEnd.Line + for offset > 0 { + b := source[offset] + if b != ' ' && b != '\t' && b != '\n' && b != '\r' { + break + } + // About to move offset → offset-1. The line decreases when the byte + // we're moving onto is `\n` (the `\n` itself is on the prior line). + if source[offset-1] == '\n' { + line-- + } + offset-- + } + return ast.Position{Offset: offset, Line: line, Column: reportedEnd.Column} +} + +// getChildren returns the direct children of an AST element, sorted by position. +// Children from access modifier entitlement types are excluded so that comments +// between the access modifier and the declaration keyword are not misclassified +// as trailing on the NominalType inside access(X). +func getChildren(node ast.Element) []ast.Element { + // Collect elements from the access modifier so we can exclude them. + excluded := map[ast.Element]bool{} + if decl, ok := node.(ast.Declaration); ok { + access := decl.DeclarationAccess() + if access != nil { + access.Walk(func(child ast.Element) { + if child != nil { + excluded[child] = true + } + }) + } + } + + var children []ast.Element + node.Walk(func(child ast.Element) { + if child != nil && !excluded[child] { + children = append(children, child) + } + }) + sort.Slice(children, func(i, j int) bool { + return children[i].StartPosition().Offset < children[j].StartPosition().Offset + }) + return children +} + +// HasTrailing returns true if the element has trailing comment groups. +func (cm *CommentMap) HasTrailing(n ast.Element) bool { + return len(cm.Trailing[n]) > 0 +} + +// HasLeadingLineComment reports whether n has a leading `//`-style comment. +// Does not remove comments from the map. +func (cm *CommentMap) HasLeadingLineComment(n ast.Element) bool { + for _, g := range cm.Leading[n] { + for _, c := range g.Comments { + if c.Kind == KindLine || c.Kind == KindDocLine { + return true + } + } + } + return false +} + +// MoveTrailingToLeading transfers all comment groups from cm.Trailing[from] +// to the front of cm.Leading[to]. Used by renderers that need to render an +// inter-token comment as leading-of-next instead of trailing-of-prev so the +// comment renders in a position that re-parses to the same attach key +// (avoids idempotence flips where a comment between two tokens of one +// declaration lands as Trailing on one pass and SameLine/Leading on another). +func (cm *CommentMap) MoveTrailingToLeading(from, to ast.Element) { + trailing := cm.Trailing[from] + if len(trailing) == 0 { + return + } + delete(cm.Trailing, from) + cm.Leading[to] = append(trailing, cm.Leading[to]...) +} + +// MoveSameLineToLeading moves the same-line comment from cm.SameLine[from] to +// the front of cm.Leading[to]. No-op if SameLine[from] is empty. See +// MoveTrailingToLeading for rationale. +func (cm *CommentMap) MoveSameLineToLeading(from, to ast.Element) { + g := cm.SameLine[from] + if g == nil { + return + } + delete(cm.SameLine, from) + cm.Leading[to] = append([]*CommentGroup{g}, cm.Leading[to]...) +} + +// blankLineBetween returns true if there is at least one blank line between +// positions a and b (i.e., the line gap is > 1). +func blankLineBetween(a, b ast.Position) bool { + return b.Line-a.Line > 1 +} diff --git a/formatter/trivia/attach_test.go b/formatter/trivia/attach_test.go new file mode 100644 index 000000000..4acdceb52 --- /dev/null +++ b/formatter/trivia/attach_test.go @@ -0,0 +1,391 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package trivia + +import ( + "testing" + + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/parser" +) + +func parse(t *testing.T, source string) *ast.Program { + t.Helper() + program, err := parser.ParseProgram(nil, []byte(source), parser.Config{}) + if err != nil { + t.Fatalf("parse error: %v", err) + } + return program +} + +func attachComments(t *testing.T, source string) (*ast.Program, *CommentMap) { + t.Helper() + program := parse(t, source) + src := []byte(source) + comments := Scan(src) + groups := Group(comments, src) + cm := Attach(program, groups, src) + return program, cm +} + +func TestAttach_FileHeader(t *testing.T) { + source := `// copyright 2024 +// all rights reserved + +access(all) fun main() {} +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + if len(cm.Header) != 1 { + t.Fatalf("expected 1 header group, got %d", len(cm.Header)) + } + if cm.Header[0].Comments[0].Text != "// copyright 2024" { + t.Errorf("header text = %q", cm.Header[0].Comments[0].Text) + } + + // No leading on the function (header is separated by blank line) + leading := cm.Leading[decls[0]] + if len(leading) != 0 { + t.Errorf("expected no leading on decl, got %d", len(leading)) + } +} + +func TestAttach_FileFooter(t *testing.T) { + // No blank line before comment → trailing of last decl, not footer + source := `access(all) fun main() {} +// trailing comment +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + if len(cm.Footer) != 0 { + t.Errorf("expected no footer, got %d", len(cm.Footer)) + } + trailing := cm.Trailing[decls[0]] + if len(trailing) != 1 { + t.Fatalf("expected 1 trailing group, got %d", len(trailing)) + } + if trailing[0].Comments[0].Text != "// trailing comment" { + t.Errorf("trailing text = %q", trailing[0].Comments[0].Text) + } +} + +func TestAttach_FooterWithBlankLine(t *testing.T) { + // Blank line before comment → true footer + source := `access(all) fun main() {} + +// footer comment +` + _, cm := attachComments(t, source) + + if len(cm.Footer) != 1 { + t.Fatalf("expected 1 footer group, got %d", len(cm.Footer)) + } + if cm.Footer[0].Comments[0].Text != "// footer comment" { + t.Errorf("footer text = %q", cm.Footer[0].Comments[0].Text) + } +} + +func TestAttach_LeadingOnFunction(t *testing.T) { + source := `// this function does something +access(all) fun main() {} +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + if len(cm.Header) != 0 { + t.Errorf("expected no header, got %d", len(cm.Header)) + } + + leading := cm.Leading[decls[0]] + if len(leading) != 1 { + t.Fatalf("expected 1 leading group, got %d", len(leading)) + } + if leading[0].Comments[0].Text != "// this function does something" { + t.Errorf("leading text = %q", leading[0].Comments[0].Text) + } +} + +func TestAttach_TrailingAfterFunction(t *testing.T) { + source := `access(all) fun a() {} +// after a +access(all) fun b() {} +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + // "// after a" is right below a() with no blank line → trailing of a + trailing := cm.Trailing[decls[0]] + if len(trailing) != 1 { + t.Fatalf("expected 1 trailing group on a, got %d", len(trailing)) + } + if trailing[0].Comments[0].Text != "// after a" { + t.Errorf("trailing text = %q", trailing[0].Comments[0].Text) + } +} + +func TestAttach_SameLine(t *testing.T) { + source := `let x = 1 // inline +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + sl := cm.SameLine[decls[0]] + if sl == nil { + t.Fatal("expected same-line comment on declaration") + } + if sl.Comments[0].Text != "// inline" { + t.Errorf("same-line text = %q", sl.Comments[0].Text) + } +} + +func TestAttach_BetweenDeclarations(t *testing.T) { + source := `access(all) fun a() {} + +// belongs to b (blank line above) +access(all) fun b() {} +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + // Blank line between a and comment → leading of b + leading := cm.Leading[decls[1]] + if len(leading) != 1 { + t.Fatalf("expected 1 leading group on b, got %d", len(leading)) + } + if leading[0].Comments[0].Text != "// belongs to b (blank line above)" { + t.Errorf("leading text = %q", leading[0].Comments[0].Text) + } +} + +func TestAttach_DocComment(t *testing.T) { + source := `/// This is a doc comment +/// for the function below +access(all) fun documented() {} +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + leading := cm.Leading[decls[0]] + if len(leading) != 1 { + t.Fatalf("expected 1 leading group, got %d", len(leading)) + } + if len(leading[0].Comments) != 2 { + t.Fatalf("expected 2 comments in group, got %d", len(leading[0].Comments)) + } + if leading[0].Comments[0].Kind != KindDocLine { + t.Errorf("expected DocLine, got %s", leading[0].Comments[0].Kind) + } +} + +func TestAttach_HeaderAndLeading(t *testing.T) { + source := `// file header + +// leading on fun +access(all) fun main() {} +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + if len(cm.Header) != 1 { + t.Fatalf("expected 1 header group, got %d", len(cm.Header)) + } + + leading := cm.Leading[decls[0]] + if len(leading) != 1 { + t.Fatalf("expected 1 leading group, got %d", len(leading)) + } +} + +func TestAttach_InsideFunctionBody(t *testing.T) { + source := `access(all) fun main() { + let x = 1 + // between statements + let y = 2 +} +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + // The comment should be attached somewhere inside the function + // Verify it's not at the top level + if len(cm.Header) != 0 { + t.Errorf("expected no header, got %d", len(cm.Header)) + } + if len(cm.Footer) != 0 { + t.Errorf("expected no footer, got %d", len(cm.Footer)) + } + if len(cm.Leading[decls[0]]) != 0 { + t.Errorf("expected no leading on function, got %d", len(cm.Leading[decls[0]])) + } + + // The comment should be attached to some inner node + totalComments := 0 + for _, groups := range cm.Leading { + for _, g := range groups { + totalComments += len(g.Comments) + } + } + for _, groups := range cm.Trailing { + for _, g := range groups { + totalComments += len(g.Comments) + } + } + if totalComments == 0 { + t.Error("comment inside function body was not attached to any node") + } +} + +func TestAttach_EmptyMap(t *testing.T) { + source := `access(all) fun main() {} +` + _, cm := attachComments(t, source) + + if !cm.IsEmpty() { + t.Error("expected empty comment map for source without comments") + } +} + +func TestAttach_Take(t *testing.T) { + source := `// leading +access(all) fun main() {} // same-line +// trailing +access(all) fun other() {} +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + leading, sameLine, trailing := cm.Take(decls[0]) + + if len(leading) != 1 { + t.Errorf("Take leading: got %d, want 1", len(leading)) + } + if sameLine == nil { + t.Error("Take sameLine: got nil, want comment") + } + if len(trailing) != 1 { + t.Errorf("Take trailing: got %d, want 1", len(trailing)) + } + + // After Take, the node should have no comments + leading2, sameLine2, trailing2 := cm.Take(decls[0]) + if len(leading2) != 0 || sameLine2 != nil || len(trailing2) != 0 { + t.Error("Take should return nothing on second call") + } +} + +func TestAttach_MultipleDecls(t *testing.T) { + source := `// header + +// doc for a +access(all) fun a() {} // inline a + +// doc for b +access(all) fun b() {} + +// footer +` + program, cm := attachComments(t, source) + decls := program.Declarations() + + if len(cm.Header) != 1 { + t.Errorf("header: got %d, want 1", len(cm.Header)) + } + + if len(cm.Leading[decls[0]]) != 1 { + t.Errorf("leading on a: got %d, want 1", len(cm.Leading[decls[0]])) + } + + if cm.SameLine[decls[0]] == nil { + t.Error("expected same-line on a") + } + + if len(cm.Leading[decls[1]]) != 1 { + t.Errorf("leading on b: got %d, want 1", len(cm.Leading[decls[1]])) + } + + if len(cm.Footer) != 1 { + t.Errorf("footer: got %d, want 1", len(cm.Footer)) + } +} + +func TestTrueEndPosition(t *testing.T) { + tests := []struct { + name string + source string + reportedAt int // offset of the reported end + reportedLn int // line of the reported end + wantOffset int + wantLine int + }{ + { + name: "no whitespace, no clip", + source: "abc", + reportedAt: 2, + reportedLn: 1, + wantOffset: 2, + wantLine: 1, + }, + { + name: "trailing space same line", + source: "abc ", + reportedAt: 4, + reportedLn: 1, + wantOffset: 2, // 'c' + wantLine: 1, + }, + { + name: "reported end is the newline that closes its own line", + source: "ab\n", + reportedAt: 2, // '\n' is on line 1 + reportedLn: 1, + wantOffset: 1, // 'b' + wantLine: 1, + }, + { + name: "reported end on next-line indent (Pragma/VoidExpression quirk)", + source: "#()\n //x", + reportedAt: 7, // last space of indent before '//' on line 2 + reportedLn: 2, + wantOffset: 2, // ')' + wantLine: 1, + }, + { + name: "multiple newlines walked back", + source: "ab\n\n\ncd", + reportedAt: 4, // third newline; line 3 (each newline is on the line it terminates) + reportedLn: 3, + wantOffset: 1, // 'b' + wantLine: 1, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := trueEndPosition( + ast.Position{Offset: tc.reportedAt, Line: tc.reportedLn, Column: 0}, + []byte(tc.source), + ) + if got.Offset != tc.wantOffset || got.Line != tc.wantLine { + t.Errorf("got offset=%d line=%d, want offset=%d line=%d", + got.Offset, got.Line, tc.wantOffset, tc.wantLine) + } + }) + } +} diff --git a/formatter/trivia/comment.go b/formatter/trivia/comment.go new file mode 100644 index 000000000..bd4b1fe71 --- /dev/null +++ b/formatter/trivia/comment.go @@ -0,0 +1,71 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package trivia + +import "github.com/onflow/cadence/ast" + +// Kind classifies a comment token. +type Kind int + +const ( + KindLine Kind = iota // // + KindBlock // /* */ + KindDocLine // /// + KindDocBlock // /** */ +) + +func (k Kind) String() string { + switch k { + case KindLine: + return "Line" + case KindBlock: + return "Block" + case KindDocLine: + return "DocLine" + case KindDocBlock: + return "DocBlock" + default: + return "Unknown" + } +} + +// Comment is a single comment token extracted from source bytes. +// Text includes delimiters (e.g. "// foo" or "/* bar */"). +type Comment struct { + Kind Kind + Start ast.Position + End ast.Position // position of last byte of the comment + Text string +} + +// CommentGroup is a sequence of adjacent comments separated only by +// whitespace (no blank lines). A blank line starts a new group. +type CommentGroup struct { + Comments []Comment +} + +// StartPos returns the position of the first byte of the group. +func (g *CommentGroup) StartPos() ast.Position { + return g.Comments[0].Start +} + +// EndPos returns the position of the last byte of the group. +func (g *CommentGroup) EndPos() ast.Position { + return g.Comments[len(g.Comments)-1].End +} diff --git a/formatter/trivia/group.go b/formatter/trivia/group.go new file mode 100644 index 000000000..c744de5bc --- /dev/null +++ b/formatter/trivia/group.go @@ -0,0 +1,98 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package trivia + +// Group partitions a slice of comments into CommentGroups. +// Adjacent comments separated only by whitespace (no blank lines, no code) +// form a single group. A blank line or any non-whitespace content between +// comments starts a new group. +func Group(comments []Comment, source []byte) []*CommentGroup { + if len(comments) == 0 { + return nil + } + + groups := make([]*CommentGroup, 0, 1) + current := &CommentGroup{ + Comments: []Comment{comments[0]}, + } + + for i := 1; i < len(comments); i++ { + prev := comments[i-1] + curr := comments[i] + + if commentsSeparated(prev, curr, source) { + groups = append(groups, current) + current = &CommentGroup{ + Comments: []Comment{curr}, + } + } else { + current.Comments = append(current.Comments, curr) + } + } + + groups = append(groups, current) + return groups +} + +// commentsSeparated returns true if there is a blank line, non-whitespace +// content between two comments, or the first comment is an end-of-line +// comment (shares its line with code). +func commentsSeparated(a, b Comment, source []byte) bool { + // Blank line (line gap > 1) always separates + if b.Start.Line-a.End.Line > 1 { + return true + } + + // Check for non-whitespace between the comments (code in between) + startOff := a.End.Offset + 1 + endOff := b.Start.Offset + if startOff < endOff && startOff < len(source) { + if endOff > len(source) { + endOff = len(source) + } + for _, c := range source[startOff:endOff] { + if c != ' ' && c != '\t' && c != '\n' && c != '\r' { + return true + } + } + } + + // End-of-line comments (code before the comment on the same line) + // are never grouped with comments on subsequent lines + if b.Start.Line > a.End.Line && hasCodeBefore(a, source) { + return true + } + + return false +} + +// hasCodeBefore returns true if there is non-whitespace content before +// the comment on the same line (making it an end-of-line comment). +func hasCodeBefore(c Comment, source []byte) bool { + lineStart := c.Start.Offset + for lineStart > 0 && source[lineStart-1] != '\n' { + lineStart-- + } + for i := lineStart; i < c.Start.Offset; i++ { + if source[i] != ' ' && source[i] != '\t' { + return true + } + } + return false +} diff --git a/formatter/trivia/scanner.go b/formatter/trivia/scanner.go new file mode 100644 index 000000000..ce2cb76d9 --- /dev/null +++ b/formatter/trivia/scanner.go @@ -0,0 +1,264 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package trivia + +import "github.com/onflow/cadence/ast" + +// Scan extracts all comments from Cadence source bytes. +// It correctly skips comment-like sequences inside string literals +// and string template interpolations. +func Scan(source []byte) []Comment { + s := &scanner{source: source, line: 1} + return s.scan() +} + +type scanner struct { + source []byte + pos int + line int + col int +} + +func (s *scanner) position() ast.Position { + return ast.Position{ + Offset: s.pos, + Line: s.line, + Column: s.col, + } +} + +func (s *scanner) advance() { + if s.pos >= len(s.source) { + return + } + if s.source[s.pos] == '\n' { + s.pos++ + s.line++ + s.col = 0 + } else { + s.pos++ + s.col++ + } +} + +func (s *scanner) peek() byte { + if s.pos+1 < len(s.source) { + return s.source[s.pos+1] + } + return 0 +} + +func (s *scanner) scan() []Comment { + var comments []Comment + for s.pos < len(s.source) { + ch := s.source[s.pos] + switch { + case ch == '"': + s.skipString() + case ch == '/' && s.peek() == '/': + comments = append(comments, s.scanLineComment()) + case ch == '/' && s.peek() == '*': + comments = append(comments, s.scanBlockComment()) + default: + s.advance() + } + } + return comments +} + +// scanLineComment consumes a line comment (// ...) starting at the current +// position (which must be at the first '/' of '//'). Returns the comment +// with Kind set to either KindLine or KindDocLine. +func (s *scanner) scanLineComment() Comment { + start := s.position() + startOff := s.pos + + // Determine kind: + // /// is doc-line only if 4th char is NOT / + // //// is a regular line comment + kind := KindLine + if s.pos+2 < len(s.source) && s.source[s.pos+2] == '/' { + if s.pos+3 >= len(s.source) || s.source[s.pos+3] != '/' { + kind = KindDocLine + } + } + + // Consume until newline or EOF (newline is NOT part of the comment) + for s.pos < len(s.source) && s.source[s.pos] != '\n' { + s.advance() + } + + text := string(s.source[startOff:s.pos]) + + // End position: last character of the comment (same line as start) + endOff := s.pos - 1 + if endOff < startOff { + endOff = startOff + } + end := ast.Position{ + Offset: endOff, + Line: start.Line, + Column: start.Column + (endOff - startOff), + } + + return Comment{Kind: kind, Start: start, End: end, Text: text} +} + +// scanBlockComment consumes a block comment starting at the current position +// (which must be at '/'). Handles nested block comments. Returns the comment +// with Kind set to either KindBlock or KindDocBlock. +func (s *scanner) scanBlockComment() Comment { + start := s.position() + startOff := s.pos + + // Determine kind: + // /** is doc-block if char after /** is NOT * and NOT / + // /**/ is regular empty block + // /*** is regular block + kind := KindBlock + if s.pos+2 < len(s.source) && s.source[s.pos+2] == '*' { + if s.pos+3 < len(s.source) && s.source[s.pos+3] != '*' && s.source[s.pos+3] != '/' { + kind = KindDocBlock + } + } + + s.advance() // skip / + s.advance() // skip * + depth := 1 + + var end ast.Position + for s.pos < len(s.source) && depth > 0 { + if s.source[s.pos] == '/' && s.peek() == '*' { + depth++ + s.advance() + s.advance() + } else if s.source[s.pos] == '*' && s.peek() == '/' { + depth-- + if depth == 0 { + s.advance() // skip * + end = s.position() + s.advance() // skip / + break + } + s.advance() + s.advance() + } else { + s.advance() + } + } + + // Unterminated block comment + if depth > 0 { + end = s.position() + if end.Offset > startOff { + end.Offset-- + if end.Column > 0 { + end.Column-- + } + } + } + + text := string(s.source[startOff:s.pos]) + return Comment{Kind: kind, Start: start, End: end, Text: text} +} + +// skipString consumes a string literal starting at the current position +// (which must be at '"'). Handles escape sequences and string template +// interpolations \(expr). +func (s *scanner) skipString() { + s.advance() // skip opening " + for s.pos < len(s.source) { + ch := s.source[s.pos] + switch ch { + case '"': + s.advance() + return + case '\\': + s.advance() // skip backslash + if s.pos < len(s.source) { + if s.source[s.pos] == '(' { + s.advance() // skip ( + s.skipStringTemplate() + } else { + s.advance() // skip escaped character + } + } + case '\n': + // Invalid string termination; stop to avoid getting stuck + return + default: + s.advance() + } + } +} + +// skipStringTemplate consumes a string template interpolation expression. +// Called after \( has been consumed. Tracks nested parentheses and handles +// nested string literals within the expression. Comments inside templates +// are skipped (not extracted) since template expressions are preserved verbatim. +func (s *scanner) skipStringTemplate() { + depth := 1 + for s.pos < len(s.source) && depth > 0 { + ch := s.source[s.pos] + switch ch { + case '(': + depth++ + s.advance() + case ')': + depth-- + s.advance() + case '"': + s.skipString() + case '/': + if s.peek() == '/' { + // Line comment inside template — skip to end of line + for s.pos < len(s.source) && s.source[s.pos] != '\n' { + s.advance() + } + } else if s.peek() == '*' { + s.skipNestedBlockComment() + } else { + s.advance() + } + default: + s.advance() + } + } +} + +// skipNestedBlockComment consumes a block comment without recording it. +// Used inside string templates where comments are preserved verbatim. +func (s *scanner) skipNestedBlockComment() { + s.advance() // / + s.advance() // * + depth := 1 + for s.pos < len(s.source) && depth > 0 { + if s.source[s.pos] == '/' && s.peek() == '*' { + depth++ + s.advance() + s.advance() + } else if s.source[s.pos] == '*' && s.peek() == '/' { + depth-- + s.advance() + s.advance() + } else { + s.advance() + } + } +} diff --git a/formatter/trivia/scanner_test.go b/formatter/trivia/scanner_test.go new file mode 100644 index 000000000..818b438c2 --- /dev/null +++ b/formatter/trivia/scanner_test.go @@ -0,0 +1,423 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package trivia + +import ( + "testing" +) + +func TestScan(t *testing.T) { + tests := []struct { + name string + source string + expected []struct { + kind Kind + text string + line int + col int + } + }{ + { + name: "basic line comment", + source: "// hello", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindLine, "// hello", 1, 0}, + }, + }, + { + name: "basic block comment", + source: "/* hello */", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindBlock, "/* hello */", 1, 0}, + }, + }, + { + name: "doc-line comment", + source: "/// doc comment", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindDocLine, "/// doc comment", 1, 0}, + }, + }, + { + name: "doc-block comment", + source: "/** doc block */", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindDocBlock, "/** doc block */", 1, 0}, + }, + }, + { + name: "four slashes is regular line", + source: "//// not doc", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindLine, "//// not doc", 1, 0}, + }, + }, + { + name: "empty block comment is regular", + source: "/**/", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindBlock, "/**/", 1, 0}, + }, + }, + { + name: "triple star block is regular", + source: "/*** stars ***/", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindBlock, "/*** stars ***/", 1, 0}, + }, + }, + { + name: "nested block comments", + source: "/* outer /* inner */ outer */", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindBlock, "/* outer /* inner */ outer */", 1, 0}, + }, + }, + { + name: "comment-like inside string", + source: `"// not a comment"`, + expected: []struct { + kind Kind + text string + line int + col int + }{}, + }, + { + name: "comment-like inside string template", + source: `"\(a /* not */ + b)"`, + expected: []struct { + kind Kind + text string + line int + col int + }{}, + }, + { + name: "multiple comments", + source: "let x = 1 // first\nlet y = 2 // second", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindLine, "// first", 1, 10}, + {KindLine, "// second", 2, 10}, + }, + }, + { + name: "mixed comment kinds", + source: "// line\n/* block */\n/// doc", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindLine, "// line", 1, 0}, + {KindBlock, "/* block */", 2, 0}, + {KindDocLine, "/// doc", 3, 0}, + }, + }, + { + name: "empty input", + source: "", + expected: []struct { + kind Kind + text string + line int + col int + }{}, + }, + { + name: "comment at EOF without newline", + source: "let x = 1 // trailing", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindLine, "// trailing", 1, 10}, + }, + }, + { + name: "multiline block comment", + source: "/* line 1\n line 2 */", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindBlock, "/* line 1\n line 2 */", 1, 0}, + }, + }, + { + name: "string with escaped quote", + source: `"escaped \" quote" // real comment`, + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindLine, "// real comment", 1, 19}, + }, + }, + { + name: "string with backslash at end", + source: `"test\\" // comment`, + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindLine, "// comment", 1, 9}, + }, + }, + { + name: "nested string in template", + source: `"\("inner")" // outer`, + expected: []struct { + kind Kind + text string + line int + col int + }{ + // "\("inner")" is a string with template containing "inner" + // then // outer is a real comment + {KindLine, "// outer", 1, 13}, + }, + }, + { + name: "comment with only slashes", + source: "//", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindLine, "//", 1, 0}, + }, + }, + { + name: "doc line at end of file", + source: "///", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindDocLine, "///", 1, 0}, + }, + }, + { + name: "comment after code", + source: "access(all) fun main() {} // end", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindLine, "// end", 1, 26}, + }, + }, + { + name: "deeply nested block comments", + source: "/* a /* b /* c */ b */ a */", + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindBlock, "/* a /* b /* c */ b */ a */", 1, 0}, + }, + }, + { + name: "template with nested parens", + source: `"\(f(g(x)))" // after`, + expected: []struct { + kind Kind + text string + line int + col int + }{ + {KindLine, "// after", 1, 13}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Scan([]byte(tt.source)) + + if len(got) != len(tt.expected) { + t.Fatalf("got %d comments, want %d.\ngot: %v", len(got), len(tt.expected), got) + } + + for i, exp := range tt.expected { + c := got[i] + if c.Kind != exp.kind { + t.Errorf("comment[%d].Kind = %s, want %s", i, c.Kind, exp.kind) + } + if c.Text != exp.text { + t.Errorf("comment[%d].Text = %q, want %q", i, c.Text, exp.text) + } + if c.Start.Line != exp.line { + t.Errorf("comment[%d].Start.Line = %d, want %d", i, c.Start.Line, exp.line) + } + if c.Start.Column != exp.col { + t.Errorf("comment[%d].Start.Column = %d, want %d", i, c.Start.Column, exp.col) + } + } + }) + } +} + +func TestGroup(t *testing.T) { + tests := []struct { + name string + source string + expected []int // number of comments in each group + }{ + { + name: "single comment", + source: "// one", + expected: []int{1}, + }, + { + name: "two adjacent line comments", + source: "// first\n// second", + expected: []int{2}, + }, + { + name: "two comments with blank line", + source: "// first\n\n// second", + expected: []int{1, 1}, + }, + { + name: "three comments: two grouped then one", + source: "// a\n// b\n\n// c", + expected: []int{2, 1}, + }, + { + name: "block then line on next line", + source: "/* block */\n// line", + expected: []int{2}, + }, + { + name: "block then line with blank line", + source: "/* block */\n\n// line", + expected: []int{1, 1}, + }, + { + name: "empty input", + source: "", + expected: nil, + }, + { + name: "multiline block then line adjacent", + source: "/* line1\nline2 */\n// after", + expected: []int{2}, + }, + { + name: "multiline block then line with blank", + source: "/* line1\nline2 */\n\n// after", + expected: []int{1, 1}, + }, + { + name: "comments separated by code", + source: "// before\nlet x = 1 // after", + expected: []int{1, 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src := []byte(tt.source) + comments := Scan(src) + groups := Group(comments, src) + + if tt.expected == nil { + if groups != nil { + t.Fatalf("expected nil groups, got %d", len(groups)) + } + return + } + + if len(groups) != len(tt.expected) { + t.Fatalf("got %d groups, want %d", len(groups), len(tt.expected)) + } + + for i, expCount := range tt.expected { + if len(groups[i].Comments) != expCount { + t.Errorf("group[%d] has %d comments, want %d", + i, len(groups[i].Comments), expCount) + } + } + }) + } +} diff --git a/formatter/trivia/semicolon.go b/formatter/trivia/semicolon.go new file mode 100644 index 000000000..e72e16e49 --- /dev/null +++ b/formatter/trivia/semicolon.go @@ -0,0 +1,52 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package trivia + +import "github.com/onflow/cadence/ast" + +// ScanSemicolons walks the AST and checks the original source bytes after +// each statement/declaration's end position for a trailing semicolon. +// Returns a set of elements that had trailing semicolons in the source. +func ScanSemicolons(source []byte, prog *ast.Program) map[ast.Element]bool { + result := make(map[ast.Element]bool) + for _, decl := range prog.Declarations() { + checkSemicolon(source, decl, result) + decl.Walk(func(child ast.Element) { + if child != nil { + checkSemicolon(source, child, result) + } + }) + } + return result +} + +func checkSemicolon(source []byte, elem ast.Element, result map[ast.Element]bool) { + end := elem.EndPosition(nil) + if end.Offset < 0 || end.Offset >= len(source) { + return + } + // Scan forward from end position, skipping spaces/tabs (not newlines). + i := end.Offset + 1 + for i < len(source) && (source[i] == ' ' || source[i] == '\t') { + i++ + } + if i < len(source) && source[i] == ';' { + result[elem] = true + } +} diff --git a/formatter/trivia/semicolon_test.go b/formatter/trivia/semicolon_test.go new file mode 100644 index 000000000..4153b8149 --- /dev/null +++ b/formatter/trivia/semicolon_test.go @@ -0,0 +1,71 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package trivia + +import ( + "testing" + + "github.com/onflow/cadence/parser" +) + +func TestScanSemicolons_Found(t *testing.T) { + source := []byte("access(all) let x: Int = 1;\n") + prog, err := parser.ParseProgram(nil, source, parser.Config{}) + if err != nil { + t.Fatalf("parse error: %v", err) + } + result := ScanSemicolons(source, prog) + if len(result) == 0 { + t.Fatal("expected to find semicolons") + } +} + +func TestScanSemicolons_None(t *testing.T) { + source := []byte("access(all) let x: Int = 1\n") + prog, err := parser.ParseProgram(nil, source, parser.Config{}) + if err != nil { + t.Fatalf("parse error: %v", err) + } + result := ScanSemicolons(source, prog) + // The map may have entries but none should mark a real semicolon + for elem, has := range result { + if has { + t.Fatalf("unexpected semicolon on element at %v", elem.StartPosition()) + } + } +} + +func TestScanSemicolons_InsideString(t *testing.T) { + source := []byte(`access(all) let x: String = "hello;"` + "\n") + prog, err := parser.ParseProgram(nil, source, parser.Config{}) + if err != nil { + t.Fatalf("parse error: %v", err) + } + result := ScanSemicolons(source, prog) + // The semicolon is inside a string — it should not be detected as a + // trailing semicolon on any AST node. The only declaration is the + // variable, which ends with the closing quote. + decls := prog.Declarations() + if len(decls) != 1 { + t.Fatalf("expected 1 declaration, got %d", len(decls)) + } + if result[decls[0]] { + t.Error("semicolon inside string should not be detected") + } +} diff --git a/formatter/verify/verify.go b/formatter/verify/verify.go new file mode 100644 index 000000000..cc9b6f9ed --- /dev/null +++ b/formatter/verify/verify.go @@ -0,0 +1,147 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package verify + +import ( + "fmt" + + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/parser" +) + +// RoundTrip parses both original and formatted source and structurally +// compares the ASTs. Returns nil if they are equivalent (ignoring positions +// and whitespace). Returns an error describing the first difference found. +func RoundTrip(original, formatted []byte) error { + origProg, err := parser.ParseProgram(nil, original, parser.Config{}) + if err != nil { + return fmt.Errorf("original parse error: %w", err) + } + + fmtProg, err := parser.ParseProgram(nil, formatted, parser.Config{}) + if err != nil { + return fmt.Errorf("formatted parse error: %w", err) + } + + return comparePrograms(origProg, fmtProg) +} + +func comparePrograms(a, b *ast.Program) error { + aDecls := a.Declarations() + bDecls := b.Declarations() + + if len(aDecls) != len(bDecls) { + return fmt.Errorf("declaration count mismatch: original=%d formatted=%d", + len(aDecls), len(bDecls)) + } + + // Split imports from non-imports — the formatter may reorder imports + aImports, aNonImports := splitDecls(aDecls) + bImports, bNonImports := splitDecls(bDecls) + + // Imports: compare as multiset (same imports, any order) + if err := compareImportSets(aImports, bImports); err != nil { + return err + } + + // Non-imports: compare in order + if len(aNonImports) != len(bNonImports) { + return fmt.Errorf("non-import declaration count mismatch: original=%d formatted=%d", + len(aNonImports), len(bNonImports)) + } + for i := range aNonImports { + if err := compareElements(aNonImports[i], bNonImports[i], fmt.Sprintf("decl[%d]", i)); err != nil { + return err + } + } + + return nil +} + +func splitDecls(decls []ast.Declaration) (imports, other []ast.Declaration) { + for _, d := range decls { + if _, ok := d.(*ast.ImportDeclaration); ok { + imports = append(imports, d) + } else { + other = append(other, d) + } + } + return +} + +func compareImportSets(a, b []ast.Declaration) error { + if len(a) != len(b) { + return fmt.Errorf("import count mismatch: original=%d formatted=%d", len(a), len(b)) + } + + aSet := make(map[string]bool) + for _, d := range a { + aSet[d.(fmt.Stringer).String()] = true + } + for _, d := range b { + key := d.(fmt.Stringer).String() + if !aSet[key] { + return fmt.Errorf("formatted has extra import: %s", key) + } + } + return nil +} + +func compareElements(a, b ast.Element, path string) error { + if a == nil && b == nil { + return nil + } + if a == nil || b == nil { + return fmt.Errorf("%s: nil mismatch (original=%v formatted=%v)", path, a, b) + } + + // Compare element types + if a.ElementType() != b.ElementType() { + return fmt.Errorf("%s: element type mismatch (original=%s formatted=%s)", + path, a.ElementType(), b.ElementType()) + } + + // Recursively compare children + aChildren := collectChildren(a) + bChildren := collectChildren(b) + + if len(aChildren) != len(bChildren) { + return fmt.Errorf("%s: child count mismatch (original=%d formatted=%d)", + path, len(aChildren), len(bChildren)) + } + + for i := range aChildren { + childPath := fmt.Sprintf("%s.child[%d]", path, i) + if err := compareElements(aChildren[i], bChildren[i], childPath); err != nil { + return err + } + } + + return nil +} + +func collectChildren(elem ast.Element) []ast.Element { + var children []ast.Element + elem.Walk(func(child ast.Element) { + if child != nil { + children = append(children, child) + } + }) + return children +}