From 53628972371a113937140210685ac00534e490a6 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 15 Mar 2024 08:31:48 -0700 Subject: [PATCH] Add `flow cadence lint` command (#1448) --- cmd/flow/main.go | 4 - go.mod | 4 +- go.sum | 3 +- internal/cadence/cadence.go | 1 + internal/cadence/lint.go | 236 +++++++++++++++++++++++ internal/cadence/lint_test.go | 324 ++++++++++++++++++++++++++++++++ internal/cadence/linter.go | 342 ++++++++++++++++++++++++++++++++++ internal/cadence/stdlib.go | 304 ++++++++++++++++++++++++++++++ internal/command/command.go | 16 +- internal/command/result.go | 5 + internal/test/test.go | 18 +- 11 files changed, 1238 insertions(+), 19 deletions(-) create mode 100644 internal/cadence/lint.go create mode 100644 internal/cadence/lint_test.go create mode 100644 internal/cadence/linter.go create mode 100644 internal/cadence/stdlib.go diff --git a/cmd/flow/main.go b/cmd/flow/main.go index 9ccf802b2..15af895c4 100644 --- a/cmd/flow/main.go +++ b/cmd/flow/main.go @@ -156,8 +156,4 @@ func main() { if err := cmd.Execute(); err != nil { util.Exit(1, err.Error()) } - - if status := *test.TestCommand.Status; status > 0 { - os.Exit(int(status)) - } } diff --git a/go.mod b/go.mod index 31485e4f8..9e553ad7b 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/getsentry/sentry-go v0.27.0 github.com/go-git/go-git/v5 v5.11.0 github.com/gosuri/uilive v0.0.4 + github.com/logrusorgru/aurora/v4 v4.0.0 github.com/manifoldco/promptui v0.9.0 github.com/onflow/cadence v0.42.9 github.com/onflow/cadence-tools/languageserver v0.33.3 @@ -154,7 +155,6 @@ require ( github.com/libp2p/go-msgio v0.3.0 // indirect github.com/lmars/go-slip10 v0.0.0-20190606092855-400ba44fee12 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect - github.com/logrusorgru/aurora/v4 v4.0.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -199,7 +199,7 @@ require ( github.com/prometheus/procfs v0.12.0 // indirect github.com/psiemens/graceland v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/rs/cors v1.8.0 // indirect github.com/sethvargo/go-retry v0.2.3 // indirect diff --git a/go.sum b/go.sum index 41f236213..ad79c0170 100644 --- a/go.sum +++ b/go.sum @@ -1038,8 +1038,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qq github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rjeczalik/notify v0.9.1/go.mod h1:rKwnCoCGeuQnwBtTSPL9Dad03Vh2n40ePRrjvIXnJho= github.com/robertkrimen/otto v0.0.0-20170205013659-6a77b7cbc37d/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= diff --git a/internal/cadence/cadence.go b/internal/cadence/cadence.go index 2c83aa094..4ed438db0 100644 --- a/internal/cadence/cadence.go +++ b/internal/cadence/cadence.go @@ -44,4 +44,5 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(languageserver.Cmd) + lintCommand.AddToParent(Cmd) } diff --git a/internal/cadence/lint.go b/internal/cadence/lint.go new file mode 100644 index 000000000..29b7b506f --- /dev/null +++ b/internal/cadence/lint.go @@ -0,0 +1,236 @@ +/* + * Flow CLI + * + * Copyright 2019 Dapper Labs, Inc. + * + * 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 cadence + +import ( + "fmt" + "strings" + + "golang.org/x/exp/slices" + + "github.com/logrusorgru/aurora/v4" + "github.com/spf13/cobra" + + "github.com/onflow/cadence/tools/analysis" + "github.com/onflow/flowkit" + "github.com/onflow/flowkit/output" + + "github.com/onflow/flow-cli/internal/command" +) + +type lintFlagsCollection struct{} + +type fileResult struct { + FilePath string + Diagnostics []analysis.Diagnostic +} + +type lintResult struct { + Results []fileResult + exitCode int +} + +var _ command.ResultWithExitCode = &lintResult{} + +var lintFlags = lintFlagsCollection{} + +var lintCommand = &command.Command{ + Cmd: &cobra.Command{ + Use: "lint [files]", + Short: "Lint Cadence code to identify potential issues or errors", + Example: "flow cadence lint **/*.cdc", + Args: cobra.MinimumNArgs(1), + }, + Flags: &lintFlags, + RunS: lint, +} + +type severity string + +const ( + errorSeverity severity = "error" + warningSeverity severity = "warning" +) + +func lint( + args []string, + globalFlags command.GlobalFlags, + logger output.Logger, + flow flowkit.Services, + state *flowkit.State, +) (command.Result, error) { + filePaths := args + result, err := lintFiles(state, filePaths...) + if err != nil { + return nil, err + } + + return result, nil +} + +func lintFiles( + state *flowkit.State, + filePaths ...string, +) ( + *lintResult, + error, +) { + l := newLinter(state) + results := make([]fileResult, 0) + exitCode := 0 + + for _, location := range filePaths { + diagnostics, err := l.lintFile(location) + if err != nil { + return nil, err + } + + // Sort for consistent output + sortDiagnostics(diagnostics) + results = append(results, fileResult{ + FilePath: location, + Diagnostics: diagnostics, + }) + + // Set the exitCode to 1 if any of the diagnostics are error-level + // In the future, this may be configurable + for _, diagnostic := range diagnostics { + severity := getDiagnosticSeverity(diagnostic) + if severity == errorSeverity { + exitCode = 1 + break + } + } + } + + return &lintResult{ + Results: results, + exitCode: exitCode, + }, nil +} + +func getDiagnosticSeverity( + diagnostic analysis.Diagnostic, +) severity { + if isErrorDiagnostic(diagnostic) { + return errorSeverity + } + return warningSeverity +} + +// Sort diagnostics in order of precedence: start pos -> category -> message +func sortDiagnostics( + diagnostics []analysis.Diagnostic, +) { + slices.SortFunc(diagnostics, func(a analysis.Diagnostic, b analysis.Diagnostic) int { + if r := a.Range.StartPos.Offset - b.Range.StartPos.Offset; r != 0 { + return r + } + + if r := strings.Compare(a.Category, b.Category); r != 0 { + return r + } + + return strings.Compare(a.Message, b.Message) + }) +} + +func renderDiagnostic(diagnostic analysis.Diagnostic) string { + categoryColor := aurora.RedFg + if getDiagnosticSeverity(diagnostic) == warningSeverity { + categoryColor = aurora.YellowFg + } + + startPos := diagnostic.Range.StartPos + locationText := fmt.Sprintf("%s:%d:%d:", diagnostic.Location.String(), startPos.Line, startPos.Column) + categoryText := fmt.Sprintf("%s:", diagnostic.Category) + + return fmt.Sprintf("%s %s %s", + aurora.Gray(12, locationText).String(), + aurora.Colorize(categoryText, categoryColor).String(), + diagnostic.Message, + ) +} + +func (r *lintResult) countProblems() (int, int) { + numErrors := 0 + numWarnings := 0 + for _, result := range r.Results { + for _, diagnostic := range result.Diagnostics { + if isErrorDiagnostic(diagnostic) { + numErrors++ + } else { + numWarnings++ + } + } + } + return numErrors, numWarnings +} + +func (r *lintResult) String() string { + var sb strings.Builder + + for _, result := range r.Results { + for _, diagnostic := range result.Diagnostics { + sb.WriteString(fmt.Sprintf("%s\n\n", renderDiagnostic(diagnostic))) + } + } + + var color aurora.Color + numErrors, numWarnings := r.countProblems() + if numErrors > 0 { + color = aurora.RedFg + } else if numWarnings > 0 { + color = aurora.YellowFg + } + + total := numErrors + numWarnings + if total > 0 { + sb.WriteString(aurora.Colorize(fmt.Sprintf("%d %s (%d %s, %d %s)", total, pluralize("problem", total), numErrors, pluralize("error", numErrors), numWarnings, pluralize("warning", numWarnings)), color).String()) + } else { + sb.WriteString(aurora.Green("Lint passed").String()) + } + + return sb.String() +} + +func (r *lintResult) JSON() interface{} { + return r +} + +func (r *lintResult) Oneliner() string { + numErrors, numWarnings := r.countProblems() + total := numErrors + numWarnings + + if total > 0 { + return fmt.Sprintf("%d %s (%d %s, %d %s)", total, pluralize("problem", total), numErrors, pluralize("error", numErrors), numWarnings, pluralize("warning", numWarnings)) + } + return "Lint passed" +} + +func (r *lintResult) ExitCode() int { + return r.exitCode +} + +func pluralize(word string, count int) string { + if count == 1 { + return word + } + return word + "s" +} diff --git a/internal/cadence/lint_test.go b/internal/cadence/lint_test.go new file mode 100644 index 000000000..99d025b55 --- /dev/null +++ b/internal/cadence/lint_test.go @@ -0,0 +1,324 @@ +/* + * Flow CLI + * + * Copyright 2019 Dapper Labs, Inc. + * + * 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 cadence + +import ( + "testing" + + "github.com/onflow/cadence/runtime/ast" + "github.com/onflow/cadence/runtime/common" + "github.com/onflow/cadence/tools/analysis" + "github.com/onflow/flow-go-sdk/crypto" + "github.com/onflow/flowkit" + "github.com/onflow/flowkit/config" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func Test_Lint(t *testing.T) { + state := setupMockState(t) + + // Test this to make sure that lintResult exit codes are actually propogated to CLI result + t.Run("results.exitCode exported via result.ExitCode()", func(t *testing.T) { + results := lintResult{ + exitCode: 999, + } + require.Equal(t, 999, results.ExitCode()) + }) + + t.Run("lints file with no issues", func(t *testing.T) { + results, error := lintFiles(state, "NoError.cdc") + require.NoError(t, error) + + require.Equal(t, &lintResult{ + Results: []fileResult{ + { + FilePath: "NoError.cdc", + Diagnostics: []analysis.Diagnostic{}, + }, + }, + exitCode: 0, + }, results) + }) + + t.Run("lints file with import", func(t *testing.T) { + results, error := lintFiles(state, "foo/WithImports.cdc") + require.NoError(t, error) + + // Should not have results for imported file, only for the file being linted + require.Equal(t, &lintResult{ + Results: []fileResult{ + { + FilePath: "foo/WithImports.cdc", + Diagnostics: []analysis.Diagnostic{}, + }, + }, + exitCode: 0, + }, results) + }) + + t.Run("lints multiple files", func(t *testing.T) { + results, error := lintFiles(state, "NoError.cdc", "foo/WithImports.cdc") + require.NoError(t, error) + + require.Equal(t, &lintResult{ + Results: []fileResult{ + { + FilePath: "NoError.cdc", + Diagnostics: []analysis.Diagnostic{}, + }, + { + FilePath: "foo/WithImports.cdc", + Diagnostics: []analysis.Diagnostic{}, + }, + }, + exitCode: 0, + }, results) + }) + + t.Run("lints file with warning", func(t *testing.T) { + results, error := lintFiles(state, "LintWarning.cdc") + require.NoError(t, error) + + require.Equal(t, &lintResult{ + Results: []fileResult{ + { + FilePath: "LintWarning.cdc", + Diagnostics: []analysis.Diagnostic{ + { + Category: "removal-hint", + Message: "unnecessary force operator", + Location: common.StringLocation("LintWarning.cdc"), + Range: ast.Range{ + StartPos: ast.Position{Line: 4, Column: 11, Offset: 59}, + EndPos: ast.Position{Line: 4, Column: 12, Offset: 60}, + }, + }, + }, + }, + }, + exitCode: 0, + }, results) + }) + + t.Run("lints file with error", func(t *testing.T) { + results, error := lintFiles(state, "LintError.cdc") + require.NoError(t, error) + + require.Equal(t, &lintResult{ + Results: []fileResult{ + { + FilePath: "LintError.cdc", + Diagnostics: []analysis.Diagnostic{ + { + Category: "removal-hint", + Message: "unnecessary force operator", + Location: common.StringLocation("LintError.cdc"), + Range: ast.Range{ + StartPos: ast.Position{Line: 4, Column: 11, Offset: 57}, + EndPos: ast.Position{Line: 4, Column: 12, Offset: 58}, + }, + }, + { + Category: "semantic-error", + Message: "cannot find variable in this scope: `qqq`", + SecondaryMessage: "not found in this scope", + Location: common.StringLocation("LintError.cdc"), + Range: ast.Range{ + StartPos: ast.Position{Line: 5, Column: 3, Offset: 63}, + EndPos: ast.Position{Line: 5, Column: 5, Offset: 65}, + }, + }, + }, + }, + }, + exitCode: 1, + }, results) + }) + + t.Run("linter resolves imports from flowkit state", func(t *testing.T) { + results, error := lintFiles(state, "WithFlowkitImport.cdc") + require.NoError(t, error) + + require.Equal(t, results, &lintResult{ + Results: []fileResult{ + { + FilePath: "WithFlowkitImport.cdc", + Diagnostics: []analysis.Diagnostic{}, + }, + }, + exitCode: 0, + }) + }) + + t.Run("resolves stdlib imports contracts", func(t *testing.T) { + results, error := lintFiles(state, "StdlibImportsContract.cdc") + require.NoError(t, error) + + // Expects an error because getAuthAccount is only available in scripts + require.Equal(t, results, &lintResult{ + Results: []fileResult{ + { + FilePath: "StdlibImportsContract.cdc", + Diagnostics: []analysis.Diagnostic{ + { + Category: "semantic-error", + Message: "cannot find variable in this scope: `getAuthAccount`", + SecondaryMessage: "not found in this scope", + Location: common.StringLocation("StdlibImportsContract.cdc"), + Range: ast.Range{ + StartPos: ast.Position{Line: 7, Column: 13, Offset: 114}, + EndPos: ast.Position{Line: 7, Column: 26, Offset: 127}, + }, + }, + }, + }, + }, + exitCode: 1, + }) + }) + + t.Run("resolves stdlib imports transactions", func(t *testing.T) { + results, error := lintFiles(state, "StdlibImportsTransaction.cdc") + require.NoError(t, error) + + // Expects an error because getAuthAccount is only available in scripts + require.Equal(t, results, &lintResult{ + Results: []fileResult{ + { + FilePath: "StdlibImportsTransaction.cdc", + Diagnostics: []analysis.Diagnostic{ + { + Category: "semantic-error", + Message: "cannot find variable in this scope: `getAuthAccount`", + SecondaryMessage: "not found in this scope", + Location: common.StringLocation("StdlibImportsTransaction.cdc"), + Range: ast.Range{ + StartPos: ast.Position{Line: 7, Column: 13, Offset: 116}, + EndPos: ast.Position{Line: 7, Column: 26, Offset: 129}, + }, + }, + }, + }, + }, + exitCode: 1, + }) + }) + + t.Run("resolves stdlib imports scripts", func(t *testing.T) { + results, error := lintFiles(state, "StdlibImportsScript.cdc") + require.NoError(t, error) + + require.Equal(t, results, &lintResult{ + Results: []fileResult{ + { + FilePath: "StdlibImportsScript.cdc", + Diagnostics: []analysis.Diagnostic{}, + }, + }, + exitCode: 0, + }) + }) +} + +func setupMockState(t *testing.T) *flowkit.State { + // Mock file system + mockFs := afero.NewMemMapFs() + _ = afero.WriteFile(mockFs, "NoError.cdc", []byte(` + access(all) contract NoError { + access(all) fun test() {} + init() {} + } + `), 0644) + _ = afero.WriteFile(mockFs, "foo/WithImports.cdc", []byte(` + import "../NoError.cdc" + access(all) contract WithImports { + init() {} + } + `), 0644) + _ = afero.WriteFile(mockFs, "WithFlowkitImport.cdc", []byte(` + import "NoError" + access(all) contract WithFlowkitImport { + init() { + let foo = NoError.getType() + } + } + `), 0644) + _ = afero.WriteFile(mockFs, "LintWarning.cdc", []byte(` + access(all) contract LintWarning { + init() { + let x = 1! + } + }`), 0644) + _ = afero.WriteFile(mockFs, "LintError.cdc", []byte(` + access(all) contract LintError { + init() { + let x = 1! + qqq + } + }`), 0644) + _ = afero.WriteFile(mockFs, "CadenceV1Error.cdc", []byte(` + access(all) contract CadenceV1Error { + init() { + let test: PublicAccount = self.account + } + }`), 0644) + _ = afero.WriteFile(mockFs, "StdlibImportsContract.cdc", []byte(` + import Crypto + import Test + import BlockchainHelpers + access(all) contract WithImports{ + init() { + let foo = getAuthAccount(0x01) + log(RLP.getType()) + } + } + `), 0644) + _ = afero.WriteFile(mockFs, "StdlibImportsTransaction.cdc", []byte(` + import Crypto + import Test + import BlockchainHelpers + transaction { + prepare(signer: AuthAccount) { + let foo = getAuthAccount(0x01) + log(RLP.getType()) + } + } + `), 0644) + _ = afero.WriteFile(mockFs, "StdlibImportsScript.cdc", []byte(` + import Crypto + import Test + import BlockchainHelpers + access(all) fun main(): Void { + let foo = getAuthAccount(0x01) + log(RLP.getType()) + }`), 0644) + + rw := afero.Afero{Fs: mockFs} + state, err := flowkit.Init(rw, crypto.ECDSA_P256, crypto.SHA3_256) + require.NoError(t, err) + + // Mock flowkit contracts + state.Contracts().AddOrUpdate(config.Contract{ + Name: "NoError", + Location: "NoError.cdc", + }) + + return state +} diff --git a/internal/cadence/linter.go b/internal/cadence/linter.go new file mode 100644 index 000000000..b41459faf --- /dev/null +++ b/internal/cadence/linter.go @@ -0,0 +1,342 @@ +/* + * Flow CLI + * + * Copyright 2019 Dapper Labs, Inc. + * + * 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 cadence + +import ( + "fmt" + "path/filepath" + "strings" + + "errors" + + cdclint "github.com/onflow/cadence-tools/lint" + cdctests "github.com/onflow/cadence-tools/test/helpers" + "github.com/onflow/cadence/runtime/ast" + "github.com/onflow/cadence/runtime/common" + cdcerrors "github.com/onflow/cadence/runtime/errors" + "github.com/onflow/cadence/runtime/parser" + "github.com/onflow/cadence/runtime/sema" + "github.com/onflow/cadence/runtime/stdlib" + "github.com/onflow/cadence/tools/analysis" + "github.com/onflow/flowkit" + "golang.org/x/exp/maps" +) + +type linter struct { + checkers map[string]*sema.Checker + state *flowkit.State + checkerStandardConfig *sema.Config + checkerScriptConfig *sema.Config +} + +type positionedError interface { + error + ast.HasPosition +} + +// Error diagnostic categories +const ( + SemanticErrorCategory = "semantic-error" + SyntaxErrorCategory = "syntax-error" + ErrorCategory = "error" +) + +var analyzers = maps.Values(cdclint.Analyzers) + +func newLinter(state *flowkit.State) *linter { + l := &linter{ + checkers: make(map[string]*sema.Checker), + state: state, + } + + // Create checker configs for both standard and script + // Scripts have a different stdlib than contracts and transactions + l.checkerStandardConfig = l.newCheckerConfig(newStandardLibrary()) + l.checkerScriptConfig = l.newCheckerConfig(newScriptStandardLibrary()) + + return l +} + +func (l *linter) lintFile( + filePath string, +) ( + []analysis.Diagnostic, + error, +) { + diagnostics := make([]analysis.Diagnostic, 0) + location := common.StringLocation(filePath) + + code, err := l.state.ReadFile(filePath) + if err != nil { + return nil, err + } + codeStr := string(code) + + // Parse program & convert any parsing errors to diagnostics + program, parseProgramErr := parser.ParseProgram(nil, code, parser.Config{}) + if parseProgramErr != nil { + var parserErr *parser.Error + if !errors.As(parseProgramErr, &parserErr) { + return nil, fmt.Errorf("could not process parsing error: %s", parseProgramErr) + } + + checkerDiagnostics, err := getDiagnosticsFromParentError(parserErr, location, codeStr) + if err != nil { + return nil, err + } + + diagnostics = append(diagnostics, checkerDiagnostics...) + } + + // If the program is nil, nothing can be checked & analyzed so return early + if program == nil { + return diagnostics, nil + } + + // Create checker based on program type + checker, err := sema.NewChecker( + program, + location, + nil, + l.decideCheckerConfig(program), + ) + if err != nil { + return nil, err + } + + // Check the program & convert any checking errors to diagnostics + checkProgramErr := checker.Check() + if checkProgramErr != nil { + var checkerErr *sema.CheckerError + if !errors.As(checkProgramErr, &checkerErr) { + return nil, fmt.Errorf("could not process checking error: %s", checkProgramErr) + } + + checkerDiagnostics, err := getDiagnosticsFromParentError(checkerErr, location, codeStr) + if err != nil { + return nil, err + } + + diagnostics = append(diagnostics, checkerDiagnostics...) + } + + // Run analysis on the program + analysisProgram := analysis.Program{ + Program: program, + Elaboration: checker.Elaboration, + Location: checker.Location, + Code: []byte(code), + } + report := func(diagnostic analysis.Diagnostic) { + diagnostics = append(diagnostics, diagnostic) + } + analysisProgram.Run(analyzers, report) + + return diagnostics, nil +} + +// Create a new checker config with the given standard library +func (l *linter) newCheckerConfig(lib standardLibrary) *sema.Config { + return &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return lib.baseValueActivation + }, + AccessCheckMode: sema.AccessCheckModeStrict, + PositionInfoEnabled: true, // Must be enabled for linters + ExtendedElaborationEnabled: true, // Must be enabled for linters + ImportHandler: l.handleImport, + SuggestionsEnabled: true, // Must be enabled to offer semantic suggestions + AttachmentsEnabled: true, + } +} + +// Choose the checker config based on the assumed type of the program +func (l *linter) decideCheckerConfig(program *ast.Program) *sema.Config { + if program.SoleTransactionDeclaration() != nil || program.SoleContractDeclaration() != nil { + return l.checkerStandardConfig + } + + return l.checkerScriptConfig +} + +// Resolve any imports found in the program while checking +func (l *linter) handleImport( + checker *sema.Checker, + importedLocation common.Location, + _ ast.Range, +) ( + sema.Import, + error, +) { + switch importedLocation { + case stdlib.CryptoCheckerLocation: + cryptoChecker := stdlib.CryptoChecker() + return sema.ElaborationImport{ + Elaboration: cryptoChecker.Elaboration, + }, nil + case stdlib.TestContractLocation: + testChecker := stdlib.GetTestContractType().Checker + return sema.ElaborationImport{ + Elaboration: testChecker.Elaboration, + }, nil + case cdctests.BlockchainHelpersLocation: + helpersChecker := cdctests.BlockchainHelpersChecker() + return sema.ElaborationImport{ + Elaboration: helpersChecker.Elaboration, + }, nil + default: + filepath, err := l.resolveImportFilepath(importedLocation, checker.Location) + if err != nil { + return nil, err + } + + importedChecker, ok := l.checkers[filepath] + if !ok { + code, err := l.state.ReadFile(filepath) + if err != nil { + return nil, err + } + + importedProgram, err := parser.ParseProgram(nil, code, parser.Config{}) + + if err != nil { + return nil, err + } + if importedProgram == nil { + return nil, &sema.CheckerError{ + Errors: []error{fmt.Errorf("cannot import %s", importedLocation)}, + } + } + + importedChecker, err = checker.SubChecker(importedProgram, importedLocation) + if err != nil { + return nil, err + } + + l.checkers[filepath] = importedChecker + err = importedChecker.Check() + if err != nil { + return nil, err + } + } + + return sema.ElaborationImport{ + Elaboration: importedChecker.Elaboration, + }, nil + } +} + +func (l *linter) resolveImportFilepath( + location common.Location, + parentLocation common.Location, +) ( + string, + error, +) { + switch location := location.(type) { + case common.StringLocation: + // If the location is not a cadence file try getting the code by identifier + if !strings.Contains(location.String(), ".cdc") { + contract, err := l.state.Contracts().ByName(location.String()) + if err != nil { + return "", err + } + + return contract.Location, nil + } + + // If the location is a cadence file, resolve relative to the parent location + parentPath := "" + if parentLocation != nil { + parentPath = parentLocation.String() + } + + resolvedPath := filepath.Join(filepath.Dir(parentPath), location.String()) + return resolvedPath, nil + default: + return "", fmt.Errorf("unsupported location: %T", location) + } +} + +// helpers + +func getDiagnosticsFromParentError(err cdcerrors.ParentError, location common.Location, code string) ([]analysis.Diagnostic, error) { + diagnostics := make([]analysis.Diagnostic, 0) + + for _, childErr := range err.ChildErrors() { + var positionedErr positionedError + if !errors.As(childErr, &positionedErr) { + return nil, fmt.Errorf("could not process error: %s", childErr) + } + + diagnostic := convertPositionedErrorToDiagnostic(positionedErr, location, code) + if diagnostic == nil { + continue + } + + diagnostics = append(diagnostics, *diagnostic) + } + + return diagnostics, nil +} + +func convertPositionedErrorToDiagnostic( + err positionedError, + location common.Location, + code string, +) *analysis.Diagnostic { + message := err.Error() + startPosition := err.StartPosition() + endPosition := err.EndPosition(nil) + + var secondaryMessage string + var secondaryErr cdcerrors.SecondaryError + if errors.As(err, &secondaryErr) { + secondaryMessage = secondaryErr.SecondaryError() + } + + var category string + var semanticErr sema.SemanticError + var syntaxErr *parser.SyntaxError + switch { + case errors.As(err, &semanticErr): + category = SemanticErrorCategory + case errors.As(err, &syntaxErr): + category = SyntaxErrorCategory + default: + category = ErrorCategory + } + + diagnostic := analysis.Diagnostic{ + Location: location, + Category: category, + Message: message, + SecondaryMessage: secondaryMessage, + Range: ast.Range{ + StartPos: startPosition, + EndPos: endPosition, + }, + } + + return &diagnostic +} + +func isErrorDiagnostic(diagnostic analysis.Diagnostic) bool { + return diagnostic.Category == ErrorCategory || diagnostic.Category == SemanticErrorCategory || diagnostic.Category == SyntaxErrorCategory +} diff --git a/internal/cadence/stdlib.go b/internal/cadence/stdlib.go new file mode 100644 index 000000000..3f2c404dc --- /dev/null +++ b/internal/cadence/stdlib.go @@ -0,0 +1,304 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright 2019-2022 Dapper Labs, Inc. + * + * 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. + */ + +// NOTE: This file is a copy of the file https://github.com/onflow/cadence-tools/blob/master/languageserver/server/stdlib.go + +package cadence + +import ( + "github.com/onflow/cadence/runtime/common" + "github.com/onflow/cadence/runtime/errors" + "github.com/onflow/cadence/runtime/interpreter" + "github.com/onflow/cadence/runtime/sema" + "github.com/onflow/cadence/runtime/stdlib" +) + +type standardLibrary struct { + baseValueActivation *sema.VariableActivation +} + +var _ stdlib.StandardLibraryHandler = standardLibrary{} + +func (standardLibrary) ProgramLog(_ string) error { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) UnsafeRandom() (uint64, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) GetBlockAtHeight(_ uint64) (stdlib.Block, bool, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) GetCurrentBlockHeight() (uint64, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) GetAccountBalance(_ common.Address) (uint64, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) GetAccountAvailableBalance(_ common.Address) (uint64, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) CommitStorageTemporarily(_ *interpreter.Interpreter) error { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) GetStorageUsed(_ common.Address) (uint64, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) GetStorageCapacity(_ common.Address) (uint64, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) GetAccountKey(_ common.Address, _ int) (*stdlib.AccountKey, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) GetAccountContractNames(_ common.Address) ([]string, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) GetAccountContractCode(_ common.AddressLocation) ([]byte, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) EmitEvent( + _ *interpreter.Interpreter, + _ *sema.CompositeType, + _ []interpreter.Value, + _ interpreter.LocationRange, +) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) AddEncodedAccountKey(_ common.Address, _ []byte) error { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) RevokeEncodedAccountKey(_ common.Address, _ int) ([]byte, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) AddAccountKey( + _ common.Address, + _ *stdlib.PublicKey, + _ sema.HashAlgorithm, + _ int, +) ( + *stdlib.AccountKey, + error, +) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) RevokeAccountKey(_ common.Address, _ int) (*stdlib.AccountKey, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) ParseAndCheckProgram(_ []byte, _ common.Location, _ bool) (*interpreter.Program, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) UpdateAccountContractCode(_ common.AddressLocation, _ []byte) error { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) RecordContractUpdate(_ common.AddressLocation, _ *interpreter.CompositeValue) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) ContractUpdateRecorded(_ common.AddressLocation) bool { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) InterpretContract( + _ common.AddressLocation, + _ *interpreter.Program, + _ string, + _ stdlib.DeployedContractConstructorInvocation, +) (*interpreter.CompositeValue, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) TemporarilyRecordCode(_ common.AddressLocation, _ []byte) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) RemoveAccountContractCode(_ common.AddressLocation) error { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) RecordContractRemoval(_ common.AddressLocation) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) CreateAccount(_ common.Address) (address common.Address, err error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) ValidatePublicKey(_ *stdlib.PublicKey) error { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) VerifySignature( + _ []byte, + _ string, + _ []byte, + _ []byte, + _ sema.SignatureAlgorithm, + _ sema.HashAlgorithm, +) (bool, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) BLSVerifyPOP(_ *stdlib.PublicKey, _ []byte) (bool, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) Hash(_ []byte, _ string, _ sema.HashAlgorithm) ([]byte, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) AccountKeysCount(_ common.Address) (uint64, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) BLSAggregatePublicKeys(_ []*stdlib.PublicKey) (*stdlib.PublicKey, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) BLSAggregateSignatures(_ [][]byte) ([]byte, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (l standardLibrary) GenerateAccountID(_ common.Address) (uint64, error) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (l standardLibrary) ReadRandom(_ []byte) error { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) StartContractAddition(_ common.AddressLocation) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) EndContractAddition(_ common.AddressLocation) { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func (standardLibrary) IsContractBeingAdded(_ common.AddressLocation) bool { + // Implementation should never be called, + // only its definition is used for type-checking + panic(errors.NewUnreachableError()) +} + +func newStandardLibrary() (result standardLibrary) { + result.baseValueActivation = sema.NewVariableActivation(sema.BaseValueActivation) + for _, valueDeclaration := range stdlib.DefaultStandardLibraryValues(result) { + result.baseValueActivation.DeclareValue(valueDeclaration) + } + return +} + +func newScriptStandardLibrary() (result standardLibrary) { + result.baseValueActivation = sema.NewVariableActivation(sema.BaseValueActivation) + for _, declaration := range stdlib.DefaultScriptStandardLibraryValues(result) { + result.baseValueActivation.DeclareValue(declaration) + } + return +} diff --git a/internal/command/command.go b/internal/command/command.go index 478066558..822639dc9 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -67,11 +67,10 @@ type RunWithState func( ) (Result, error) type Command struct { - Cmd *cobra.Command - Flags any - Run run - RunS RunWithState - Status *int + Cmd *cobra.Command + Flags any + Run run + RunS RunWithState } const ( @@ -163,6 +162,13 @@ func (c Command) AddToParent(parent *cobra.Command) { handleError("Output Error", err) wg.Wait() + + // exit with code if result has it + exitCode := 0 + if res, ok := result.(ResultWithExitCode); ok { + exitCode = res.ExitCode() + } + os.Exit(exitCode) } bindFlags(c) diff --git a/internal/command/result.go b/internal/command/result.go index 0e4ba3f37..b6576f39a 100644 --- a/internal/command/result.go +++ b/internal/command/result.go @@ -44,6 +44,11 @@ type Result interface { JSON() any } +type ResultWithExitCode interface { + Result + ExitCode() int +} + // ContainsFlag checks if output flag is present for the provided field. func ContainsFlag(flags []string, field string) bool { for _, n := range flags { diff --git a/internal/test/test.go b/internal/test/test.go index 3b1ad4300..0d32560a4 100644 --- a/internal/test/test.go +++ b/internal/test/test.go @@ -71,8 +71,6 @@ type flagsTests struct { var testFlags = flagsTests{} -var status = 0 - var TestCommand = &command.Command{ Cmd: &cobra.Command{ Use: "test ", @@ -81,9 +79,8 @@ var TestCommand = &command.Command{ Args: cobra.MinimumNArgs(1), GroupID: "tools", }, - Flags: &testFlags, - RunS: run, - Status: &status, + Flags: &testFlags, + RunS: run, } func run( @@ -187,6 +184,7 @@ func testCode( } testResults := make(map[string]cdcTests.Results, 0) + exitCode := 0 for scriptPath, code := range testFiles { runner := runner. WithImportResolver(importResolver(scriptPath, state)). @@ -220,7 +218,7 @@ func testCode( for _, result := range testResults[scriptPath] { if result.Error != nil { - status = 1 + exitCode = 1 break } } @@ -230,6 +228,7 @@ func testCode( Results: testResults, CoverageReport: coverageReport, RandomSeed: seed, + exitCode: exitCode, }, nil } @@ -302,9 +301,10 @@ type result struct { Results map[string]cdcTests.Results CoverageReport *runtime.CoverageReport RandomSeed int64 + exitCode int } -var _ command.Result = &result{} +var _ command.ResultWithExitCode = &result{} func (r *result) JSON() any { results := make(map[string]map[string]string, len(r.Results)) @@ -376,3 +376,7 @@ func (r *result) Oneliner() string { return builder.String() } + +func (r *result) ExitCode() int { + return r.exitCode +}