diff --git a/.github/codecov.yml b/.github/codecov.yml index f0cb9583cf2..d973bcce859 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -10,19 +10,10 @@ coverage: round: down precision: 2 status: - project: + patch: # new lines default: - target: auto - threshold: 5 # Let's decrease this later. - base: parent - if_no_uploads: error - if_not_found: success - if_ci_failed: error - only_pulls: false - patch: - default: - target: auto - threshold: 5 # Let's decrease this later. + target: 80 + threshold: 10 base: auto if_no_uploads: error if_not_found: success diff --git a/.github/workflows/genesis-verify.yml b/.github/workflows/genesis-verify.yml index acc41cc99ad..a9e9a43e55a 100644 --- a/.github/workflows/genesis-verify.yml +++ b/.github/workflows/genesis-verify.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - testnet: [ "test5.gno.land" ] + testnet: [ ] # Currently, all active testnet deployment genesis.json are legacy runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index a293469bb5d..2b27a2537e1 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -1,11 +1,14 @@ # generate Go docs and publish on gh-pages branch # Live at: https://gnolang.github.io/gno -name: Go Reference Docs Deployment +name: GitHub pages (godoc & stdlib_diff) build and deploy on: push: branches: - master + pull_request: + branches: + - master workflow_dispatch: permissions: @@ -19,29 +22,39 @@ concurrency: jobs: build: - if: ${{ github.repository == 'gnolang/gno' }} # Alternatively, validate based on provided tokens and permissions. + if: github.repository == 'gnolang/gno' # Alternatively, validate based on provided tokens and permissions. runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: go.mod - - run: echo "GOROOT=$(go env GOROOT)" >> $GITHUB_ENV - - run: echo $GOROOT + # Use the goroot at the top of the project to compare with the GnoVM + # stdlib, rather than the one in stdlib_diff (which may have a go.mod with + # a different toolchain version). + - run: echo "GOROOT_SAVE=$(go env GOROOT)" >> $GITHUB_ENV - run: "cd misc/stdlib_diff && make gen" - run: "cd misc/gendocs && make install gen" - run: "mkdir -p pages_output/stdlib_diff" - run: | cp -r misc/gendocs/godoc/* pages_output/ cp -r misc/stdlib_diff/stdlib_diff/* pages_output/stdlib_diff/ + + # These two last steps will be skipped on pull requests - uses: actions/configure-pages@v5 id: pages + if: github.event_name != 'pull_request' + - uses: actions/upload-pages-artifact@v3 + if: github.event_name != 'pull_request' with: path: ./pages_output deploy: - if: ${{ github.repository == 'gnolang/gno' }} # Alternatively, validate based on provided tokens and permissions. + if: > + github.repository == 'gnolang/gno' && + github.ref == 'refs/heads/master' && + github.event_name == 'push' runs-on: ubuntu-latest environment: name: github-pages diff --git a/contribs/github-bot/internal/config/config.go b/contribs/github-bot/internal/config/config.go index 43a505ad15a..5db91a73413 100644 --- a/contribs/github-bot/internal/config/config.go +++ b/contribs/github-bot/internal/config/config.go @@ -33,12 +33,18 @@ func Config(gh *client.GitHub) ([]AutomaticCheck, []ManualCheck) { auto := []AutomaticCheck{ { Description: "Maintainers must be able to edit this pull request ([more info](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork))", - If: c.CreatedFromFork(), - Then: r.MaintainerCanModify(), + If: c.And( + c.BaseBranch("^master$"), + c.CreatedFromFork(), + ), + Then: r.MaintainerCanModify(), }, { Description: "Changes to 'docs' folder must be reviewed/authored by at least one devrel and one tech-staff", - If: c.FileChanged(gh, "^docs/"), + If: c.And( + c.BaseBranch("^master$"), + c.FileChanged(gh, "^docs/"), + ), Then: r.And( r.Or( r.AuthorInTeam(gh, "tech-staff"), @@ -57,7 +63,10 @@ func Config(gh *client.GitHub) ([]AutomaticCheck, []ManualCheck) { }, { Description: "Pending initial approval by a review team member, or review from tech-staff", - If: c.Not(c.AuthorInTeam(gh, "tech-staff")), + If: c.And( + c.BaseBranch("^master$"), + c.Not(c.AuthorInTeam(gh, "tech-staff")), + ), Then: r. If(r.Or( r.ReviewByOrgMembers(gh).WithDesiredState(utils.ReviewStateApproved), @@ -91,7 +100,7 @@ func Config(gh *client.GitHub) ([]AutomaticCheck, []ManualCheck) { { Description: "Determine if infra needs to be updated before merging", If: c.And( - c.BaseBranch("master"), + c.BaseBranch("^master$"), c.Or( c.FileChanged(gh, `Dockerfile`), c.FileChanged(gh, `^misc/deployments`), diff --git a/contribs/gnodev/go.mod b/contribs/gnodev/go.mod index a4c106a24ee..0ad16ba9bb3 100644 --- a/contribs/gnodev/go.mod +++ b/contribs/gnodev/go.mod @@ -79,6 +79,7 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect diff --git a/contribs/gnodev/go.sum b/contribs/gnodev/go.sum index e87c2de6441..f4bf32aafd5 100644 --- a/contribs/gnodev/go.sum +++ b/contribs/gnodev/go.sum @@ -226,6 +226,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= diff --git a/contribs/gnofaucet/go.mod b/contribs/gnofaucet/go.mod index 32d2e322098..88c05e0d778 100644 --- a/contribs/gnofaucet/go.mod +++ b/contribs/gnofaucet/go.mod @@ -32,6 +32,7 @@ require ( github.com/rs/cors v1.11.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.34.0 // indirect diff --git a/contribs/gnofaucet/go.sum b/contribs/gnofaucet/go.sum index 5b3cfdc3289..e6743b75960 100644 --- a/contribs/gnofaucet/go.sum +++ b/contribs/gnofaucet/go.sum @@ -126,6 +126,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= diff --git a/contribs/gnogenesis/go.mod b/contribs/gnogenesis/go.mod index 5b28c8774c8..8af370f8169 100644 --- a/contribs/gnogenesis/go.mod +++ b/contribs/gnogenesis/go.mod @@ -36,6 +36,7 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/zondax/hid v0.9.2 // indirect github.com/zondax/ledger-go v0.14.3 // indirect go.etcd.io/bbolt v1.3.11 // indirect diff --git a/contribs/gnogenesis/go.sum b/contribs/gnogenesis/go.sum index dcd853e9148..e3462f9c431 100644 --- a/contribs/gnogenesis/go.sum +++ b/contribs/gnogenesis/go.sum @@ -133,6 +133,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= github.com/zondax/hid v0.9.2/go.mod h1:l5wttcP0jwtdLjqjMMWFVEE7d1zO0jvSPA9OPZxWpEM= github.com/zondax/ledger-go v0.14.3 h1:wEpJt2CEcBJ428md/5MgSLsXLBos98sBOyxNmCjfUCw= diff --git a/contribs/gnogenesis/internal/verify/verify.go b/contribs/gnogenesis/internal/verify/verify.go index 9022711ce49..c69f41cad4d 100644 --- a/contribs/gnogenesis/internal/verify/verify.go +++ b/contribs/gnogenesis/internal/verify/verify.go @@ -12,7 +12,10 @@ import ( "github.com/gnolang/gno/tm2/pkg/commands" ) -var errInvalidGenesisState = errors.New("invalid genesis state type") +var ( + errInvalidGenesisState = errors.New("invalid genesis state type") + errInvalidTxSignature = errors.New("invalid tx signature") +) type verifyCfg struct { common.Cfg @@ -60,10 +63,32 @@ func execVerify(cfg *verifyCfg, io commands.IO) error { } // Validate the initial transactions - for _, tx := range state.Txs { + for index, tx := range state.Txs { if validateErr := tx.Tx.ValidateBasic(); validateErr != nil { return fmt.Errorf("invalid transacton, %w", validateErr) } + + // Genesis txs can only be signed by 1 account. + // Basic tx validation ensures there is at least 1 signer + signer := tx.Tx.GetSignatures()[0] + + // Grab the signature bytes of the tx. + // Genesis transactions are signed with + // account number and sequence set to 0 + signBytes, err := tx.Tx.GetSignBytes(genesis.ChainID, 0, 0) + if err != nil { + return fmt.Errorf("unable to get tx signature payload, %w", err) + } + + // Verify the signature using the public key + if !signer.PubKey.VerifyBytes(signBytes, signer.Signature) { + return fmt.Errorf( + "%w #%d, by signer %s", + errInvalidTxSignature, + index, + signer.PubKey.Address(), + ) + } } // Validate the initial balances diff --git a/contribs/gnogenesis/internal/verify/verify_test.go b/contribs/gnogenesis/internal/verify/verify_test.go index 130bd5e09bc..cc80c0423de 100644 --- a/contribs/gnogenesis/internal/verify/verify_test.go +++ b/contribs/gnogenesis/internal/verify/verify_test.go @@ -8,8 +8,12 @@ import ( "github.com/gnolang/gno/gno.land/pkg/gnoland" "github.com/gnolang/gno/tm2/pkg/bft/types" "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/crypto/ed25519" "github.com/gnolang/gno/tm2/pkg/crypto/mock" + "github.com/gnolang/gno/tm2/pkg/sdk/bank" + "github.com/gnolang/gno/tm2/pkg/std" "github.com/gnolang/gno/tm2/pkg/testutils" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -63,6 +67,99 @@ func TestGenesis_Verify(t *testing.T) { require.Error(t, cmdErr) }) + t.Run("invalid tx signature", func(t *testing.T) { + t.Parallel() + + g := getValidTestGenesis() + + testTable := []struct { + name string + signBytesFn func(tx *std.Tx) []byte + }{ + { + name: "invalid chain ID", + signBytesFn: func(tx *std.Tx) []byte { + // Sign the transaction, but with a chain ID + // that differs from the genesis chain ID + signBytes, err := tx.GetSignBytes(g.ChainID+"wrong", 0, 0) + require.NoError(t, err) + + return signBytes + }, + }, + { + name: "invalid account params", + signBytesFn: func(tx *std.Tx) []byte { + // Sign the transaction, but with an + // account number that is not 0 + signBytes, err := tx.GetSignBytes(g.ChainID, 10, 0) + require.NoError(t, err) + + return signBytes + }, + }, + } + + for _, testCase := range testTable { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + tempFile, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + // Generate the transaction + signer := ed25519.GenPrivKey() + + sendMsg := bank.MsgSend{ + FromAddress: signer.PubKey().Address(), + ToAddress: signer.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 10)), + } + + tx := std.Tx{ + Msgs: []std.Msg{sendMsg}, + Fee: std.Fee{ + GasWanted: 1000000, + GasFee: std.NewCoin("ugnot", 20), + }, + } + + // Sign the transaction + signBytes := testCase.signBytesFn(&tx) + + signature, err := signer.Sign(signBytes) + require.NoError(t, err) + + tx.Signatures = append(tx.Signatures, std.Signature{ + PubKey: signer.PubKey(), + Signature: signature, + }) + + g.AppState = gnoland.GnoGenesisState{ + Balances: []gnoland.Balance{}, + Txs: []gnoland.TxWithMetadata{ + { + Tx: tx, + }, + }, + } + + require.NoError(t, g.SaveAs(tempFile.Name())) + + // Create the command + cmd := NewVerifyCmd(commands.NewTestIO()) + args := []string{ + "--genesis-path", + tempFile.Name(), + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + assert.ErrorIs(t, cmdErr, errInvalidTxSignature) + }) + } + }) + t.Run("invalid balances", func(t *testing.T) { t.Parallel() diff --git a/contribs/gnohealth/go.mod b/contribs/gnohealth/go.mod index 203dac360b7..76d7cd9c437 100644 --- a/contribs/gnohealth/go.mod +++ b/contribs/gnohealth/go.mod @@ -23,6 +23,7 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/stretchr/testify v1.10.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.34.0 // indirect diff --git a/contribs/gnohealth/go.sum b/contribs/gnohealth/go.sum index e51cadf1564..3c8b5de45f2 100644 --- a/contribs/gnohealth/go.sum +++ b/contribs/gnohealth/go.sum @@ -118,6 +118,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= diff --git a/contribs/gnokeykc/go.mod b/contribs/gnokeykc/go.mod index 73e51f6b25e..3abcf3d834f 100644 --- a/contribs/gnokeykc/go.mod +++ b/contribs/gnokeykc/go.mod @@ -38,6 +38,7 @@ require ( github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/zondax/hid v0.9.2 // indirect github.com/zondax/ledger-go v0.14.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/contribs/gnokeykc/go.sum b/contribs/gnokeykc/go.sum index 7a058c85750..6b4f81dfcf5 100644 --- a/contribs/gnokeykc/go.sum +++ b/contribs/gnokeykc/go.sum @@ -139,6 +139,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= diff --git a/contribs/gnomigrate/go.mod b/contribs/gnomigrate/go.mod index 83d88c354e7..96f6dc9bdc6 100644 --- a/contribs/gnomigrate/go.mod +++ b/contribs/gnomigrate/go.mod @@ -33,6 +33,7 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect go.etcd.io/bbolt v1.3.11 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.34.0 // indirect diff --git a/contribs/gnomigrate/go.sum b/contribs/gnomigrate/go.sum index dcd853e9148..e3462f9c431 100644 --- a/contribs/gnomigrate/go.sum +++ b/contribs/gnomigrate/go.sum @@ -133,6 +133,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= github.com/zondax/hid v0.9.2/go.mod h1:l5wttcP0jwtdLjqjMMWFVEE7d1zO0jvSPA9OPZxWpEM= github.com/zondax/ledger-go v0.14.3 h1:wEpJt2CEcBJ428md/5MgSLsXLBos98sBOyxNmCjfUCw= diff --git a/docs/gno-tooling/cli/gnokey/state-changing-calls.md b/docs/gno-tooling/cli/gnokey/state-changing-calls.md index 79a777cca51..b301e99be56 100644 --- a/docs/gno-tooling/cli/gnokey/state-changing-calls.md +++ b/docs/gno-tooling/cli/gnokey/state-changing-calls.md @@ -99,12 +99,11 @@ Next, let's configure the `addpkg` subcommand to publish this package to the the `example/p/` folder, the command will look like this: ```bash -gnokey maketx addpkg \ +gnokey maketx addpkg \ -pkgpath "gno.land/p//hello_world" \ -pkgdir "." \ --send "" \ -gas-fee 10000000ugnot \ --gas-wanted 8000000 \ +-gas-wanted 200000 \ -broadcast \ -chainid portal-loop \ -remote "https://rpc.gno.land:443" @@ -114,15 +113,14 @@ Once we have added a desired [namespace](../../../concepts/namespaces.md) to upl a keypair name to use to execute the transaction: ```bash -gnokey maketx addpkg \ +gnokey maketx addpkg \ -pkgpath "gno.land/p/examplenamespace/hello_world" \ -pkgdir "." \ --send "" \ -gas-fee 10000000ugnot \ -gas-wanted 200000 \ -broadcast \ -chainid portal-loop \ --remote "https://rpc.gno.land:443" +-remote "https://rpc.gno.land:443" \ mykey ``` diff --git a/examples/gno.land/p/demo/avl/pager/pager.gno b/examples/gno.land/p/demo/avl/pager/pager.gno index f5f909a473d..6a77ba3eb6f 100644 --- a/examples/gno.land/p/demo/avl/pager/pager.gno +++ b/examples/gno.land/p/demo/avl/pager/pager.gno @@ -5,13 +5,13 @@ import ( "net/url" "strconv" - "gno.land/p/demo/avl" + "gno.land/p/demo/avl/rotree" "gno.land/p/demo/ufmt" ) // Pager is a struct that holds the AVL tree and pagination parameters. type Pager struct { - Tree avl.ITree + Tree rotree.IReadOnlyTree PageQueryParam string SizeQueryParam string DefaultPageSize int @@ -37,7 +37,7 @@ type Item struct { } // NewPager creates a new Pager with default values. -func NewPager(tree avl.ITree, defaultPageSize int, reversed bool) *Pager { +func NewPager(tree rotree.IReadOnlyTree, defaultPageSize int, reversed bool) *Pager { return &Pager{ Tree: tree, PageQueryParam: "page", diff --git a/examples/gno.land/p/demo/mux/handler.gno b/examples/gno.land/p/demo/mux/handler.gno index 835d050a52c..4d937dbacab 100644 --- a/examples/gno.land/p/demo/mux/handler.gno +++ b/examples/gno.land/p/demo/mux/handler.gno @@ -7,6 +7,8 @@ type Handler struct { type HandlerFunc func(*ResponseWriter, *Request) -// TODO: type ErrHandlerFunc func(*ResponseWriter, *Request) error -// TODO: NotFoundHandler +type ErrHandlerFunc func(*ResponseWriter, *Request) error + +type NotFoundHandler func(*ResponseWriter, *Request) + // TODO: AutomaticIndex diff --git a/examples/gno.land/p/demo/mux/request.gno b/examples/gno.land/p/demo/mux/request.gno index 7b5b74da91b..eaa2f287069 100644 --- a/examples/gno.land/p/demo/mux/request.gno +++ b/examples/gno.land/p/demo/mux/request.gno @@ -18,24 +18,29 @@ type Request struct { // GetVar retrieves a variable from the path based on routing rules. func (r *Request) GetVar(key string) string { - var ( - handlerParts = strings.Split(r.HandlerPath, "/") - reqParts = strings.Split(r.Path, "/") - ) - - for i := 0; i < len(handlerParts); i++ { - handlerPart := handlerParts[i] + handlerParts := strings.Split(r.HandlerPath, "/") + reqParts := strings.Split(r.Path, "/") + reqIndex := 0 + for handlerIndex := 0; handlerIndex < len(handlerParts); handlerIndex++ { + handlerPart := handlerParts[handlerIndex] switch { case handlerPart == "*": - // XXX: implement a/b/*/d/e - panic("not implemented") + // If a wildcard "*" is found, consume all remaining segments + wildcardParts := reqParts[reqIndex:] + reqIndex = len(reqParts) // Consume all remaining segments + return strings.Join(wildcardParts, "/") // Return all remaining segments as a string case strings.HasPrefix(handlerPart, "{") && strings.HasSuffix(handlerPart, "}"): + // If a variable of the form {param} is found we compare it with the key parameter := handlerPart[1 : len(handlerPart)-1] if parameter == key { - return reqParts[i] + return reqParts[reqIndex] } + reqIndex++ default: - // continue + if reqIndex >= len(reqParts) || handlerPart != reqParts[reqIndex] { + return "" + } + reqIndex++ } } diff --git a/examples/gno.land/p/demo/mux/request_test.gno b/examples/gno.land/p/demo/mux/request_test.gno index 5f8088b4964..24c611c1f9d 100644 --- a/examples/gno.land/p/demo/mux/request_test.gno +++ b/examples/gno.land/p/demo/mux/request_test.gno @@ -1,8 +1,10 @@ package mux import ( - "fmt" "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" ) func TestRequest_GetVar(t *testing.T) { @@ -12,28 +14,35 @@ func TestRequest_GetVar(t *testing.T) { getVarKey string expectedOutput string }{ + {"users/{id}", "users/123", "id", "123"}, {"users/123", "users/123", "id", ""}, {"users/{id}", "users/123", "nonexistent", ""}, - {"a/{b}/c/{d}", "a/42/c/1337", "b", "42"}, - {"a/{b}/c/{d}", "a/42/c/1337", "d", "1337"}, - {"{a}", "foo", "a", "foo"}, - // TODO: wildcards: a/*/c - // TODO: multiple patterns per slashes: a/{b}-{c}/d - } + {"users/{userId}/posts/{postId}", "users/123/posts/456", "userId", "123"}, + {"users/{userId}/posts/{postId}", "users/123/posts/456", "postId", "456"}, + + // Wildcards + {"*", "users/123", "*", "users/123"}, + {"*", "users/123/posts/456", "*", "users/123/posts/456"}, + {"*", "users/123/posts/456/comments/789", "*", "users/123/posts/456/comments/789"}, + {"users/*", "users/john/posts", "*", "john/posts"}, + {"users/*/comments", "users/jane/comments", "*", "jane/comments"}, + {"api/*/posts/*", "api/v1/posts/123", "*", "v1/posts/123"}, + // wildcards and parameters + {"api/{version}/*", "api/v1/user/settings", "version", "v1"}, + } for _, tt := range cases { - name := fmt.Sprintf("%s-%s", tt.handlerPath, tt.reqPath) + name := ufmt.Sprintf("%s-%s", tt.handlerPath, tt.reqPath) t.Run(name, func(t *testing.T) { req := &Request{ HandlerPath: tt.handlerPath, Path: tt.reqPath, } - output := req.GetVar(tt.getVarKey) - if output != tt.expectedOutput { - t.Errorf("Expected '%q, but got %q", tt.expectedOutput, output) - } + uassert.Equal(t, tt.expectedOutput, output, + "handler: %q, path: %q, key: %q", + tt.handlerPath, tt.reqPath, tt.getVarKey) }) } } diff --git a/examples/gno.land/p/demo/mux/router.gno b/examples/gno.land/p/demo/mux/router.gno index fe6bf70abdf..4fca43a0378 100644 --- a/examples/gno.land/p/demo/mux/router.gno +++ b/examples/gno.land/p/demo/mux/router.gno @@ -5,7 +5,7 @@ import "strings" // Router handles the routing and rendering logic. type Router struct { routes []Handler - NotFoundHandler HandlerFunc + NotFoundHandler NotFoundHandler } // NewRouter creates a new Router instance. @@ -23,8 +23,14 @@ func (r *Router) Render(reqPath string) string { for _, route := range r.routes { patParts := strings.Split(route.Pattern, "/") - - if len(patParts) != len(reqParts) { + wildcard := false + for _, part := range patParts { + if part == "*" { + wildcard = true + break + } + } + if !wildcard && len(patParts) != len(reqParts) { continue } @@ -34,7 +40,7 @@ func (r *Router) Render(reqPath string) string { reqPart := reqParts[i] if patPart == "*" { - continue + break } if strings.HasPrefix(patPart, "{") && strings.HasSuffix(patPart, "}") { continue @@ -63,12 +69,31 @@ func (r *Router) Render(reqPath string) string { return res.Output() } -// Handle registers a route and its handler function. +// HandleFunc registers a route and its handler function. func (r *Router) HandleFunc(pattern string, fn HandlerFunc) { route := Handler{Pattern: pattern, Fn: fn} r.routes = append(r.routes, route) } +// HandleErrFunc registers a route and its error handler function. +func (r *Router) HandleErrFunc(pattern string, fn ErrHandlerFunc) { + + // Convert ErrHandlerFunc to regular HandlerFunc + handler := func(res *ResponseWriter, req *Request) { + if err := fn(res, req); err != nil { + res.Write("Error: " + err.Error()) + } + } + + r.HandleFunc(pattern, handler) +} + +// SetNotFoundHandler sets custom message for 404 defaultNotFoundHandler. +func (r *Router) SetNotFoundHandler(handler NotFoundHandler) { + r.NotFoundHandler = handler +} + +// stripQueryString removes query string from the request path. func stripQueryString(reqPath string) string { i := strings.Index(reqPath, "?") if i == -1 { diff --git a/examples/gno.land/p/demo/mux/router_test.gno b/examples/gno.land/p/demo/mux/router_test.gno index cc6aad62146..c1c5d218165 100644 --- a/examples/gno.land/p/demo/mux/router_test.gno +++ b/examples/gno.land/p/demo/mux/router_test.gno @@ -72,7 +72,33 @@ func TestRouter_Render(t *testing.T) { }) }, }, - + { + label: "wildcard in route", + path: "hello/Alice/Bob", + expectedOutput: "Matched: Alice/Bob", + setupHandler: func(t *testing.T, r *Router) { + r.HandleFunc("hello/*", func(rw *ResponseWriter, req *Request) { + path := req.GetVar("*") + uassert.Equal(t, "Alice/Bob", path) + uassert.Equal(t, "hello/Alice/Bob", req.Path) + rw.Write("Matched: " + path) + }) + }, + }, + { + label: "wildcard in route with query string", + path: "hello/Alice/Bob?foo=bar", + expectedOutput: "Matched: Alice/Bob", + setupHandler: func(t *testing.T, r *Router) { + r.HandleFunc("hello/*", func(rw *ResponseWriter, req *Request) { + path := req.GetVar("*") + uassert.Equal(t, "Alice/Bob", path) + uassert.Equal(t, "hello/Alice/Bob?foo=bar", req.RawPath) + uassert.Equal(t, "hello/Alice/Bob", req.Path) + rw.Write("Matched: " + path) + }) + }, + }, // TODO: {"hello", "Hello, world!"}, // TODO: hello/, /hello, hello//Alice, hello/Alice/, hello/Alice/Bob, etc } diff --git a/examples/gno.land/p/moul/cow/gno.mod b/examples/gno.land/p/moul/cow/gno.mod new file mode 100644 index 00000000000..e5dec0bc5b4 --- /dev/null +++ b/examples/gno.land/p/moul/cow/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/cow diff --git a/examples/gno.land/p/moul/cow/node.gno b/examples/gno.land/p/moul/cow/node.gno new file mode 100644 index 00000000000..0c30871d7c4 --- /dev/null +++ b/examples/gno.land/p/moul/cow/node.gno @@ -0,0 +1,518 @@ +package cow + +//---------------------------------------- +// Node + +// Node represents a node in an AVL tree. +type Node struct { + key string // key is the unique identifier for the node. + value interface{} // value is the data stored in the node. + height int8 // height is the height of the node in the tree. + size int // size is the number of nodes in the subtree rooted at this node. + leftNode *Node // leftNode is the left child of the node. + rightNode *Node // rightNode is the right child of the node. +} + +// NewNode creates a new node with the given key and value. +func NewNode(key string, value interface{}) *Node { + return &Node{ + key: key, + value: value, + height: 0, + size: 1, + } +} + +// Size returns the size of the subtree rooted at the node. +func (node *Node) Size() int { + if node == nil { + return 0 + } + return node.size +} + +// IsLeaf checks if the node is a leaf node (has no children). +func (node *Node) IsLeaf() bool { + return node.height == 0 +} + +// Key returns the key of the node. +func (node *Node) Key() string { + return node.key +} + +// Value returns the value of the node. +func (node *Node) Value() interface{} { + return node.value +} + +// _copy creates a copy of the node (excluding value). +func (node *Node) _copy() *Node { + if node.height == 0 { + panic("Why are you copying a value node?") + } + return &Node{ + key: node.key, + height: node.height, + size: node.size, + leftNode: node.leftNode, + rightNode: node.rightNode, + } +} + +// Has checks if a node with the given key exists in the subtree rooted at the node. +func (node *Node) Has(key string) (has bool) { + if node == nil { + return false + } + if node.key == key { + return true + } + if node.height == 0 { + return false + } + if key < node.key { + return node.getLeftNode().Has(key) + } + return node.getRightNode().Has(key) +} + +// Get searches for a node with the given key in the subtree rooted at the node +// and returns its index, value, and whether it exists. +func (node *Node) Get(key string) (index int, value interface{}, exists bool) { + if node == nil { + return 0, nil, false + } + + if node.height == 0 { + if node.key == key { + return 0, node.value, true + } + if node.key < key { + return 1, nil, false + } + return 0, nil, false + } + + if key < node.key { + return node.getLeftNode().Get(key) + } + + rightNode := node.getRightNode() + index, value, exists = rightNode.Get(key) + index += node.size - rightNode.size + return index, value, exists +} + +// GetByIndex retrieves the key-value pair of the node at the given index +// in the subtree rooted at the node. +func (node *Node) GetByIndex(index int) (key string, value interface{}) { + if node.height == 0 { + if index == 0 { + return node.key, node.value + } + panic("GetByIndex asked for invalid index") + } + // TODO: could improve this by storing the sizes + leftNode := node.getLeftNode() + if index < leftNode.size { + return leftNode.GetByIndex(index) + } + return node.getRightNode().GetByIndex(index - leftNode.size) +} + +// Set inserts a new node with the given key-value pair into the subtree rooted at the node, +// and returns the new root of the subtree and whether an existing node was updated. +// +// XXX consider a better way to do this... perhaps split Node from Node. +func (node *Node) Set(key string, value interface{}) (newSelf *Node, updated bool) { + if node == nil { + return NewNode(key, value), false + } + + // Always create a new node for leaf nodes + if node.height == 0 { + return node.setLeaf(key, value) + } + + // Copy the node before modifying + newNode := node._copy() + if key < node.key { + newNode.leftNode, updated = node.getLeftNode().Set(key, value) + } else { + newNode.rightNode, updated = node.getRightNode().Set(key, value) + } + + if !updated { + newNode.calcHeightAndSize() + return newNode.balance(), updated + } + + return newNode, updated +} + +// setLeaf inserts a new leaf node with the given key-value pair into the subtree rooted at the node, +// and returns the new root of the subtree and whether an existing node was updated. +func (node *Node) setLeaf(key string, value interface{}) (newSelf *Node, updated bool) { + if key == node.key { + return NewNode(key, value), true + } + + if key < node.key { + return &Node{ + key: node.key, + height: 1, + size: 2, + leftNode: NewNode(key, value), + rightNode: node, + }, false + } + + return &Node{ + key: key, + height: 1, + size: 2, + leftNode: node, + rightNode: NewNode(key, value), + }, false +} + +// Remove deletes the node with the given key from the subtree rooted at the node. +// returns the new root of the subtree, the new leftmost leaf key (if changed), +// the removed value and the removal was successful. +func (node *Node) Remove(key string) ( + newNode *Node, newKey string, value interface{}, removed bool, +) { + if node == nil { + return nil, "", nil, false + } + if node.height == 0 { + if key == node.key { + return nil, "", node.value, true + } + return node, "", nil, false + } + if key < node.key { + var newLeftNode *Node + newLeftNode, newKey, value, removed = node.getLeftNode().Remove(key) + if !removed { + return node, "", value, false + } + if newLeftNode == nil { // left node held value, was removed + return node.rightNode, node.key, value, true + } + node = node._copy() + node.leftNode = newLeftNode + node.calcHeightAndSize() + node = node.balance() + return node, newKey, value, true + } + + var newRightNode *Node + newRightNode, newKey, value, removed = node.getRightNode().Remove(key) + if !removed { + return node, "", value, false + } + if newRightNode == nil { // right node held value, was removed + return node.leftNode, "", value, true + } + node = node._copy() + node.rightNode = newRightNode + if newKey != "" { + node.key = newKey + } + node.calcHeightAndSize() + node = node.balance() + return node, "", value, true +} + +// getLeftNode returns the left child of the node. +func (node *Node) getLeftNode() *Node { + return node.leftNode +} + +// getRightNode returns the right child of the node. +func (node *Node) getRightNode() *Node { + return node.rightNode +} + +// rotateRight performs a right rotation on the node and returns the new root. +// NOTE: overwrites node +// TODO: optimize balance & rotate +func (node *Node) rotateRight() *Node { + node = node._copy() + l := node.getLeftNode() + _l := l._copy() + + _lrCached := _l.rightNode + _l.rightNode = node + node.leftNode = _lrCached + + node.calcHeightAndSize() + _l.calcHeightAndSize() + + return _l +} + +// rotateLeft performs a left rotation on the node and returns the new root. +// NOTE: overwrites node +// TODO: optimize balance & rotate +func (node *Node) rotateLeft() *Node { + node = node._copy() + r := node.getRightNode() + _r := r._copy() + + _rlCached := _r.leftNode + _r.leftNode = node + node.rightNode = _rlCached + + node.calcHeightAndSize() + _r.calcHeightAndSize() + + return _r +} + +// calcHeightAndSize updates the height and size of the node based on its children. +// NOTE: mutates height and size +func (node *Node) calcHeightAndSize() { + node.height = maxInt8(node.getLeftNode().height, node.getRightNode().height) + 1 + node.size = node.getLeftNode().size + node.getRightNode().size +} + +// calcBalance calculates the balance factor of the node. +func (node *Node) calcBalance() int { + return int(node.getLeftNode().height) - int(node.getRightNode().height) +} + +// balance balances the subtree rooted at the node and returns the new root. +// NOTE: assumes that node can be modified +// TODO: optimize balance & rotate +func (node *Node) balance() (newSelf *Node) { + balance := node.calcBalance() + if balance >= -1 { + return node + } + if balance > 1 { + if node.getLeftNode().calcBalance() >= 0 { + // Left Left Case + return node.rotateRight() + } + // Left Right Case + left := node.getLeftNode() + node.leftNode = left.rotateLeft() + return node.rotateRight() + } + + if node.getRightNode().calcBalance() <= 0 { + // Right Right Case + return node.rotateLeft() + } + + // Right Left Case + right := node.getRightNode() + node.rightNode = right.rotateRight() + return node.rotateLeft() +} + +// Shortcut for TraverseInRange. +func (node *Node) Iterate(start, end string, cb func(*Node) bool) bool { + return node.TraverseInRange(start, end, true, true, cb) +} + +// Shortcut for TraverseInRange. +func (node *Node) ReverseIterate(start, end string, cb func(*Node) bool) bool { + return node.TraverseInRange(start, end, false, true, cb) +} + +// TraverseInRange traverses all nodes, including inner nodes. +// Start is inclusive and end is exclusive when ascending, +// Start and end are inclusive when descending. +// Empty start and empty end denote no start and no end. +// If leavesOnly is true, only visit leaf nodes. +// NOTE: To simulate an exclusive reverse traversal, +// just append 0x00 to start. +func (node *Node) TraverseInRange(start, end string, ascending bool, leavesOnly bool, cb func(*Node) bool) bool { + if node == nil { + return false + } + afterStart := (start == "" || start < node.key) + startOrAfter := (start == "" || start <= node.key) + beforeEnd := false + if ascending { + beforeEnd = (end == "" || node.key < end) + } else { + beforeEnd = (end == "" || node.key <= end) + } + + // Run callback per inner/leaf node. + stop := false + if (!node.IsLeaf() && !leavesOnly) || + (node.IsLeaf() && startOrAfter && beforeEnd) { + stop = cb(node) + if stop { + return stop + } + } + if node.IsLeaf() { + return stop + } + + if ascending { + // check lower nodes, then higher + if afterStart { + stop = node.getLeftNode().TraverseInRange(start, end, ascending, leavesOnly, cb) + } + if stop { + return stop + } + if beforeEnd { + stop = node.getRightNode().TraverseInRange(start, end, ascending, leavesOnly, cb) + } + } else { + // check the higher nodes first + if beforeEnd { + stop = node.getRightNode().TraverseInRange(start, end, ascending, leavesOnly, cb) + } + if stop { + return stop + } + if afterStart { + stop = node.getLeftNode().TraverseInRange(start, end, ascending, leavesOnly, cb) + } + } + + return stop +} + +// TraverseByOffset traverses all nodes, including inner nodes. +// A limit of math.MaxInt means no limit. +func (node *Node) TraverseByOffset(offset, limit int, descending bool, leavesOnly bool, cb func(*Node) bool) bool { + if node == nil { + return false + } + + // fast paths. these happen only if TraverseByOffset is called directly on a leaf. + if limit <= 0 || offset >= node.size { + return false + } + if node.IsLeaf() { + if offset > 0 { + return false + } + return cb(node) + } + + // go to the actual recursive function. + return node.traverseByOffset(offset, limit, descending, leavesOnly, cb) +} + +// TraverseByOffset traverses the subtree rooted at the node by offset and limit, +// in either ascending or descending order, and applies the callback function to each traversed node. +// If leavesOnly is true, only leaf nodes are visited. +func (node *Node) traverseByOffset(offset, limit int, descending bool, leavesOnly bool, cb func(*Node) bool) bool { + // caller guarantees: offset < node.size; limit > 0. + if !leavesOnly { + if cb(node) { + return true + } + } + first, second := node.getLeftNode(), node.getRightNode() + if descending { + first, second = second, first + } + if first.IsLeaf() { + // either run or skip, based on offset + if offset > 0 { + offset-- + } else { + cb(first) + limit-- + if limit <= 0 { + return false + } + } + } else { + // possible cases: + // 1 the offset given skips the first node entirely + // 2 the offset skips none or part of the first node, but the limit requires some of the second node. + // 3 the offset skips none or part of the first node, and the limit stops our search on the first node. + if offset >= first.size { + offset -= first.size // 1 + } else { + if first.traverseByOffset(offset, limit, descending, leavesOnly, cb) { + return true + } + // number of leaves which could actually be called from inside + delta := first.size - offset + offset = 0 + if delta >= limit { + return true // 3 + } + limit -= delta // 2 + } + } + + // because of the caller guarantees and the way we handle the first node, + // at this point we know that limit > 0 and there must be some values in + // this second node that we include. + + // => if the second node is a leaf, it has to be included. + if second.IsLeaf() { + return cb(second) + } + // => if it is not a leaf, it will still be enough to recursively call this + // function with the updated offset and limit + return second.traverseByOffset(offset, limit, descending, leavesOnly, cb) +} + +// Only used in testing... +func (node *Node) lmd() *Node { + if node.height == 0 { + return node + } + return node.getLeftNode().lmd() +} + +// Only used in testing... +func (node *Node) rmd() *Node { + if node.height == 0 { + return node + } + return node.getRightNode().rmd() +} + +func maxInt8(a, b int8) int8 { + if a > b { + return a + } + return b +} + +// Equal compares two nodes for structural equality. +// WARNING: This is an expensive operation that recursively traverses the entire tree structure. +// It should only be used in tests or when absolutely necessary. +func (node *Node) Equal(other *Node) bool { + // Handle nil cases + if node == nil || other == nil { + return node == other + } + + // Compare node properties + if node.key != other.key || + node.value != other.value || + node.height != other.height || + node.size != other.size { + return false + } + + // Compare children + leftEqual := (node.leftNode == nil && other.leftNode == nil) || + (node.leftNode != nil && other.leftNode != nil && node.leftNode.Equal(other.leftNode)) + if !leftEqual { + return false + } + + rightEqual := (node.rightNode == nil && other.rightNode == nil) || + (node.rightNode != nil && other.rightNode != nil && node.rightNode.Equal(other.rightNode)) + return rightEqual +} diff --git a/examples/gno.land/p/moul/cow/node_test.gno b/examples/gno.land/p/moul/cow/node_test.gno new file mode 100644 index 00000000000..c7225fe1ab0 --- /dev/null +++ b/examples/gno.land/p/moul/cow/node_test.gno @@ -0,0 +1,795 @@ +package cow + +import ( + "fmt" + "sort" + "strings" + "testing" +) + +func TestTraverseByOffset(t *testing.T) { + const testStrings = `Alfa +Alfred +Alpha +Alphabet +Beta +Beth +Book +Browser` + tt := []struct { + name string + desc bool + }{ + {"ascending", false}, + {"descending", true}, + } + + for _, tt := range tt { + t.Run(tt.name, func(t *testing.T) { + sl := strings.Split(testStrings, "\n") + + // sort a first time in the order opposite to how we'll be traversing + // the tree, to ensure that we are not just iterating through with + // insertion order. + sort.Strings(sl) + if !tt.desc { + reverseSlice(sl) + } + + r := NewNode(sl[0], nil) + for _, v := range sl[1:] { + r, _ = r.Set(v, nil) + } + + // then sort sl in the order we'll be traversing it, so that we can + // compare the result with sl. + reverseSlice(sl) + + var result []string + for i := 0; i < len(sl); i++ { + r.TraverseByOffset(i, 1, tt.desc, true, func(n *Node) bool { + result = append(result, n.Key()) + return false + }) + } + + if !slicesEqual(sl, result) { + t.Errorf("want %v got %v", sl, result) + } + + for l := 2; l <= len(sl); l++ { + // "slices" + for i := 0; i <= len(sl); i++ { + max := i + l + if max > len(sl) { + max = len(sl) + } + exp := sl[i:max] + actual := []string{} + + r.TraverseByOffset(i, l, tt.desc, true, func(tr *Node) bool { + actual = append(actual, tr.Key()) + return false + }) + if !slicesEqual(exp, actual) { + t.Errorf("want %v got %v", exp, actual) + } + } + } + }) + } +} + +func TestHas(t *testing.T) { + tests := []struct { + name string + input []string + hasKey string + expected bool + }{ + { + "has key in non-empty tree", + []string{"C", "A", "B", "E", "D"}, + "B", + true, + }, + { + "does not have key in non-empty tree", + []string{"C", "A", "B", "E", "D"}, + "F", + false, + }, + { + "has key in single-node tree", + []string{"A"}, + "A", + true, + }, + { + "does not have key in single-node tree", + []string{"A"}, + "B", + false, + }, + { + "does not have key in empty tree", + []string{}, + "A", + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tree *Node + for _, key := range tt.input { + tree, _ = tree.Set(key, nil) + } + + result := tree.Has(tt.hasKey) + + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestGet(t *testing.T) { + tests := []struct { + name string + input []string + getKey string + expectIdx int + expectVal interface{} + expectExists bool + }{ + { + "get existing key", + []string{"C", "A", "B", "E", "D"}, + "B", + 1, + nil, + true, + }, + { + "get non-existent key (smaller)", + []string{"C", "A", "B", "E", "D"}, + "@", + 0, + nil, + false, + }, + { + "get non-existent key (larger)", + []string{"C", "A", "B", "E", "D"}, + "F", + 5, + nil, + false, + }, + { + "get from empty tree", + []string{}, + "A", + 0, + nil, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tree *Node + for _, key := range tt.input { + tree, _ = tree.Set(key, nil) + } + + idx, val, exists := tree.Get(tt.getKey) + + if idx != tt.expectIdx { + t.Errorf("Expected index %d, got %d", tt.expectIdx, idx) + } + + if val != tt.expectVal { + t.Errorf("Expected value %v, got %v", tt.expectVal, val) + } + + if exists != tt.expectExists { + t.Errorf("Expected exists %t, got %t", tt.expectExists, exists) + } + }) + } +} + +func TestGetByIndex(t *testing.T) { + tests := []struct { + name string + input []string + idx int + expectKey string + expectVal interface{} + expectPanic bool + }{ + { + "get by valid index", + []string{"C", "A", "B", "E", "D"}, + 2, + "C", + nil, + false, + }, + { + "get by valid index (smallest)", + []string{"C", "A", "B", "E", "D"}, + 0, + "A", + nil, + false, + }, + { + "get by valid index (largest)", + []string{"C", "A", "B", "E", "D"}, + 4, + "E", + nil, + false, + }, + { + "get by invalid index (negative)", + []string{"C", "A", "B", "E", "D"}, + -1, + "", + nil, + true, + }, + { + "get by invalid index (out of range)", + []string{"C", "A", "B", "E", "D"}, + 5, + "", + nil, + true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tree *Node + for _, key := range tt.input { + tree, _ = tree.Set(key, nil) + } + + if tt.expectPanic { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected a panic but didn't get one") + } + }() + } + + key, val := tree.GetByIndex(tt.idx) + + if !tt.expectPanic { + if key != tt.expectKey { + t.Errorf("Expected key %s, got %s", tt.expectKey, key) + } + + if val != tt.expectVal { + t.Errorf("Expected value %v, got %v", tt.expectVal, val) + } + } + }) + } +} + +func TestRemove(t *testing.T) { + tests := []struct { + name string + input []string + removeKey string + expected []string + }{ + { + "remove leaf node", + []string{"C", "A", "B", "D"}, + "B", + []string{"A", "C", "D"}, + }, + { + "remove node with one child", + []string{"C", "A", "B", "D"}, + "A", + []string{"B", "C", "D"}, + }, + { + "remove node with two children", + []string{"C", "A", "B", "E", "D"}, + "C", + []string{"A", "B", "D", "E"}, + }, + { + "remove root node", + []string{"C", "A", "B", "E", "D"}, + "C", + []string{"A", "B", "D", "E"}, + }, + { + "remove non-existent key", + []string{"C", "A", "B", "E", "D"}, + "F", + []string{"A", "B", "C", "D", "E"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tree *Node + for _, key := range tt.input { + tree, _ = tree.Set(key, nil) + } + + tree, _, _, _ = tree.Remove(tt.removeKey) + + result := make([]string, 0) + tree.Iterate("", "", func(n *Node) bool { + result = append(result, n.Key()) + return false + }) + + if !slicesEqual(tt.expected, result) { + t.Errorf("want %v got %v", tt.expected, result) + } + }) + } +} + +func TestTraverse(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + "empty tree", + []string{}, + []string{}, + }, + { + "single node tree", + []string{"A"}, + []string{"A"}, + }, + { + "small tree", + []string{"C", "A", "B", "E", "D"}, + []string{"A", "B", "C", "D", "E"}, + }, + { + "large tree", + []string{"H", "D", "L", "B", "F", "J", "N", "A", "C", "E", "G", "I", "K", "M", "O"}, + []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tree *Node + for _, key := range tt.input { + tree, _ = tree.Set(key, nil) + } + + t.Run("iterate", func(t *testing.T) { + var result []string + tree.Iterate("", "", func(n *Node) bool { + result = append(result, n.Key()) + return false + }) + if !slicesEqual(tt.expected, result) { + t.Errorf("want %v got %v", tt.expected, result) + } + }) + + t.Run("ReverseIterate", func(t *testing.T) { + var result []string + tree.ReverseIterate("", "", func(n *Node) bool { + result = append(result, n.Key()) + return false + }) + expected := make([]string, len(tt.expected)) + copy(expected, tt.expected) + for i, j := 0, len(expected)-1; i < j; i, j = i+1, j-1 { + expected[i], expected[j] = expected[j], expected[i] + } + if !slicesEqual(expected, result) { + t.Errorf("want %v got %v", expected, result) + } + }) + + t.Run("TraverseInRange", func(t *testing.T) { + var result []string + start, end := "C", "M" + tree.TraverseInRange(start, end, true, true, func(n *Node) bool { + result = append(result, n.Key()) + return false + }) + expected := make([]string, 0) + for _, key := range tt.expected { + if key >= start && key < end { + expected = append(expected, key) + } + } + if !slicesEqual(expected, result) { + t.Errorf("want %v got %v", expected, result) + } + }) + }) + } +} + +func TestRotateWhenHeightDiffers(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + "right rotation when left subtree is higher", + []string{"E", "C", "A", "B", "D"}, + []string{"A", "B", "C", "E", "D"}, + }, + { + "left rotation when right subtree is higher", + []string{"A", "C", "E", "D", "F"}, + []string{"A", "C", "D", "E", "F"}, + }, + { + "left-right rotation", + []string{"E", "A", "C", "B", "D"}, + []string{"A", "B", "C", "E", "D"}, + }, + { + "right-left rotation", + []string{"A", "E", "C", "B", "D"}, + []string{"A", "B", "C", "E", "D"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tree *Node + for _, key := range tt.input { + tree, _ = tree.Set(key, nil) + } + + // perform rotation or balance + tree = tree.balance() + + // check tree structure + var result []string + tree.Iterate("", "", func(n *Node) bool { + result = append(result, n.Key()) + return false + }) + + if !slicesEqual(tt.expected, result) { + t.Errorf("want %v got %v", tt.expected, result) + } + }) + } +} + +func TestRotateAndBalance(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + "right rotation", + []string{"A", "B", "C", "D", "E"}, + []string{"A", "B", "C", "D", "E"}, + }, + { + "left rotation", + []string{"E", "D", "C", "B", "A"}, + []string{"A", "B", "C", "D", "E"}, + }, + { + "left-right rotation", + []string{"C", "A", "E", "B", "D"}, + []string{"A", "B", "C", "D", "E"}, + }, + { + "right-left rotation", + []string{"C", "E", "A", "D", "B"}, + []string{"A", "B", "C", "D", "E"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tree *Node + for _, key := range tt.input { + tree, _ = tree.Set(key, nil) + } + + tree = tree.balance() + + var result []string + tree.Iterate("", "", func(n *Node) bool { + result = append(result, n.Key()) + return false + }) + + if !slicesEqual(tt.expected, result) { + t.Errorf("want %v got %v", tt.expected, result) + } + }) + } +} + +func slicesEqual(w1, w2 []string) bool { + if len(w1) != len(w2) { + return false + } + for i := 0; i < len(w1); i++ { + if w1[0] != w2[0] { + return false + } + } + return true +} + +func maxint8(a, b int8) int8 { + if a > b { + return a + } + return b +} + +func reverseSlice(ss []string) { + for i := 0; i < len(ss)/2; i++ { + j := len(ss) - 1 - i + ss[i], ss[j] = ss[j], ss[i] + } +} + +func TestNodeStructuralSharing(t *testing.T) { + t.Run("unmodified paths remain shared", func(t *testing.T) { + root := NewNode("B", 2) + root, _ = root.Set("A", 1) + root, _ = root.Set("C", 3) + + originalRight := root.rightNode + newRoot, _ := root.Set("A", 10) + + if newRoot.rightNode != originalRight { + t.Error("Unmodified right subtree should remain shared") + } + }) + + t.Run("multiple modifications reuse shared structure", func(t *testing.T) { + // Create initial tree + root := NewNode("B", 2) + root, _ = root.Set("A", 1) + root, _ = root.Set("C", 3) + + // Store original nodes + originalRight := root.rightNode + + // First modification + mod1, _ := root.Set("A", 10) + + // Second modification + mod2, _ := mod1.Set("C", 30) + + // Check sharing in first modification + if mod1.rightNode != originalRight { + t.Error("First modification should share unmodified right subtree") + } + + // Check that second modification creates new right node + if mod2.rightNode == originalRight { + t.Error("Second modification should create new right node") + } + }) +} + +func TestNodeCopyOnWrite(t *testing.T) { + t.Run("copy preserves structure", func(t *testing.T) { + root := NewNode("B", 2) + root, _ = root.Set("A", 1) + root, _ = root.Set("C", 3) + + // Only copy non-leaf nodes + if !root.IsLeaf() { + copied := root._copy() + if copied == root { + t.Error("Copy should create new instance") + } + + // Create temporary trees to use Equal method + original := &Tree{node: root} + copiedTree := &Tree{node: copied} + if !original.Equal(copiedTree) { + t.Error("Copied node should preserve structure") + } + } + }) + + t.Run("removal copy pattern", func(t *testing.T) { + // Create a more complex tree to test removal + root := NewNode("B", 2) + root, _ = root.Set("A", 1) + root, _ = root.Set("C", 3) + root, _ = root.Set("D", 4) // Add this to ensure proper tree structure + + // Store references to original nodes + originalRight := root.rightNode + originalRightRight := originalRight.rightNode + + // Remove "A" which should only affect the left subtree + newRoot, _, _, _ := root.Remove("A") + + // Verify right subtree remains unchanged and shared + if newRoot.rightNode != originalRight { + t.Error("Right subtree should remain shared during removal of left node") + } + + // Also verify deeper nodes remain shared + if newRoot.rightNode.rightNode != originalRightRight { + t.Error("Deep right subtree should remain shared during removal") + } + + // Verify original tree is unchanged + if _, _, exists := root.Get("A"); !exists { + t.Error("Original tree should remain unchanged") + } + }) + + t.Run("copy leaf node panic", func(t *testing.T) { + leaf := NewNode("A", 1) + + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic when copying leaf node") + } + }() + + // This should panic with our specific message + leaf._copy() + }) +} + +func TestNodeEqual(t *testing.T) { + tests := []struct { + name string + node1 func() *Node + node2 func() *Node + expected bool + }{ + { + name: "nil nodes", + node1: func() *Node { return nil }, + node2: func() *Node { return nil }, + expected: true, + }, + { + name: "one nil node", + node1: func() *Node { return NewNode("A", 1) }, + node2: func() *Node { return nil }, + expected: false, + }, + { + name: "single leaf nodes equal", + node1: func() *Node { return NewNode("A", 1) }, + node2: func() *Node { return NewNode("A", 1) }, + expected: true, + }, + { + name: "single leaf nodes different key", + node1: func() *Node { return NewNode("A", 1) }, + node2: func() *Node { return NewNode("B", 1) }, + expected: false, + }, + { + name: "single leaf nodes different value", + node1: func() *Node { return NewNode("A", 1) }, + node2: func() *Node { return NewNode("A", 2) }, + expected: false, + }, + { + name: "complex trees equal", + node1: func() *Node { + n, _ := NewNode("B", 2).Set("A", 1) + n, _ = n.Set("C", 3) + return n + }, + node2: func() *Node { + n, _ := NewNode("B", 2).Set("A", 1) + n, _ = n.Set("C", 3) + return n + }, + expected: true, + }, + { + name: "complex trees different structure", + node1: func() *Node { + // Create a tree with structure: + // B + // / \ + // A D + n := NewNode("B", 2) + n, _ = n.Set("A", 1) + n, _ = n.Set("D", 4) + return n + }, + node2: func() *Node { + // Create a tree with structure: + // C + // / \ + // A D + n := NewNode("C", 3) + n, _ = n.Set("A", 1) + n, _ = n.Set("D", 4) + return n + }, + expected: false, // These trees should be different + }, + { + name: "complex trees same structure despite different insertion order", + node1: func() *Node { + n, _ := NewNode("B", 2).Set("A", 1) + n, _ = n.Set("C", 3) + return n + }, + node2: func() *Node { + n, _ := NewNode("A", 1).Set("B", 2) + n, _ = n.Set("C", 3) + return n + }, + expected: true, + }, + { + name: "truly different structures", + node1: func() *Node { + n, _ := NewNode("B", 2).Set("A", 1) + return n // Tree with just two nodes + }, + node2: func() *Node { + n, _ := NewNode("B", 2).Set("C", 3) + return n // Different two-node tree + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node1 := tt.node1() + node2 := tt.node2() + result := node1.Equal(node2) + if result != tt.expected { + t.Errorf("Expected Equal to return %v for %s", tt.expected, tt.name) + println("\nComparison failed:") + println("Tree 1:") + printTree(node1, 0) + println("Tree 2:") + printTree(node2, 0) + } + }) + } +} + +// Helper function to print tree structure +func printTree(node *Node, level int) { + if node == nil { + return + } + indent := strings.Repeat(" ", level) + println(fmt.Sprintf("%sKey: %s, Value: %v, Height: %d, Size: %d", + indent, node.key, node.value, node.height, node.size)) + printTree(node.leftNode, level+1) + printTree(node.rightNode, level+1) +} diff --git a/examples/gno.land/p/moul/cow/tree.gno b/examples/gno.land/p/moul/cow/tree.gno new file mode 100644 index 00000000000..befd0a414f6 --- /dev/null +++ b/examples/gno.land/p/moul/cow/tree.gno @@ -0,0 +1,164 @@ +// Package cow provides a Copy-on-Write (CoW) AVL tree implementation. +// This is a fork of gno.land/p/demo/avl that adds CoW functionality +// while maintaining the original AVL tree interface and properties. +// +// Copy-on-Write creates a copy of a data structure only when it is modified, +// while still presenting the appearance of a full copy. When a tree is cloned, +// it initially shares all its nodes with the original tree. Only when a +// modification is made to either the original or the clone are new nodes created, +// and only along the path from the root to the modified node. +// +// Key features: +// - O(1) cloning operation +// - Minimal memory usage through structural sharing +// - Full AVL tree functionality (self-balancing, ordered operations) +// - Thread-safe for concurrent reads of shared structures +// +// While the CoW mechanism handles structural copying automatically, users need +// to consider how to handle the values stored in the tree: +// +// 1. Simple Values (int, string, etc.): +// - These are copied by value automatically +// - No additional handling needed +// +// 2. Complex Values (structs, pointers): +// - Only the reference is copied by default +// - Users must implement their own deep copy mechanism if needed +// +// Example: +// +// // Create original tree +// original := cow.NewTree() +// original.Set("key1", "value1") +// +// // Create a clone - O(1) operation +// clone := original.Clone() +// +// // Modify clone - only affected nodes are copied +// clone.Set("key1", "modified") +// +// // Original remains unchanged +// val, _ := original.Get("key1") // Returns "value1" +package cow + +type IterCbFn func(key string, value interface{}) bool + +//---------------------------------------- +// Tree + +// The zero struct can be used as an empty tree. +type Tree struct { + node *Node +} + +// NewTree creates a new empty AVL tree. +func NewTree() *Tree { + return &Tree{ + node: nil, + } +} + +// Size returns the number of key-value pair in the tree. +func (tree *Tree) Size() int { + return tree.node.Size() +} + +// Has checks whether a key exists in the tree. +// It returns true if the key exists, otherwise false. +func (tree *Tree) Has(key string) (has bool) { + return tree.node.Has(key) +} + +// Get retrieves the value associated with the given key. +// It returns the value and a boolean indicating whether the key exists. +func (tree *Tree) Get(key string) (value interface{}, exists bool) { + _, value, exists = tree.node.Get(key) + return +} + +// GetByIndex retrieves the key-value pair at the specified index in the tree. +// It returns the key and value at the given index. +func (tree *Tree) GetByIndex(index int) (key string, value interface{}) { + return tree.node.GetByIndex(index) +} + +// Set inserts a key-value pair into the tree. +// If the key already exists, the value will be updated. +// It returns a boolean indicating whether the key was newly inserted or updated. +func (tree *Tree) Set(key string, value interface{}) (updated bool) { + newnode, updated := tree.node.Set(key, value) + tree.node = newnode + return updated +} + +// Remove removes a key-value pair from the tree. +// It returns the removed value and a boolean indicating whether the key was found and removed. +func (tree *Tree) Remove(key string) (value interface{}, removed bool) { + newnode, _, value, removed := tree.node.Remove(key) + tree.node = newnode + return value, removed +} + +// Iterate performs an in-order traversal of the tree within the specified key range. +// It calls the provided callback function for each key-value pair encountered. +// If the callback returns true, the iteration is stopped. +func (tree *Tree) Iterate(start, end string, cb IterCbFn) bool { + return tree.node.TraverseInRange(start, end, true, true, + func(node *Node) bool { + return cb(node.Key(), node.Value()) + }, + ) +} + +// ReverseIterate performs a reverse in-order traversal of the tree within the specified key range. +// It calls the provided callback function for each key-value pair encountered. +// If the callback returns true, the iteration is stopped. +func (tree *Tree) ReverseIterate(start, end string, cb IterCbFn) bool { + return tree.node.TraverseInRange(start, end, false, true, + func(node *Node) bool { + return cb(node.Key(), node.Value()) + }, + ) +} + +// IterateByOffset performs an in-order traversal of the tree starting from the specified offset. +// It calls the provided callback function for each key-value pair encountered, up to the specified count. +// If the callback returns true, the iteration is stopped. +func (tree *Tree) IterateByOffset(offset int, count int, cb IterCbFn) bool { + return tree.node.TraverseByOffset(offset, count, true, true, + func(node *Node) bool { + return cb(node.Key(), node.Value()) + }, + ) +} + +// ReverseIterateByOffset performs a reverse in-order traversal of the tree starting from the specified offset. +// It calls the provided callback function for each key-value pair encountered, up to the specified count. +// If the callback returns true, the iteration is stopped. +func (tree *Tree) ReverseIterateByOffset(offset int, count int, cb IterCbFn) bool { + return tree.node.TraverseByOffset(offset, count, false, true, + func(node *Node) bool { + return cb(node.Key(), node.Value()) + }, + ) +} + +// Equal returns true if the two trees contain the same key-value pairs. +// WARNING: This is an expensive operation that recursively traverses the entire tree structure. +// It should only be used in tests or when absolutely necessary. +func (tree *Tree) Equal(other *Tree) bool { + if tree == nil || other == nil { + return tree == other + } + return tree.node.Equal(other.node) +} + +// Clone creates a shallow copy of the tree +func (tree *Tree) Clone() *Tree { + if tree == nil { + return nil + } + return &Tree{ + node: tree.node, + } +} diff --git a/examples/gno.land/p/moul/cow/tree_test.gno b/examples/gno.land/p/moul/cow/tree_test.gno new file mode 100644 index 00000000000..6ee816455b8 --- /dev/null +++ b/examples/gno.land/p/moul/cow/tree_test.gno @@ -0,0 +1,392 @@ +package cow + +import ( + "testing" +) + +func TestNewTree(t *testing.T) { + tree := NewTree() + if tree.node != nil { + t.Error("Expected tree.node to be nil") + } +} + +func TestTreeSize(t *testing.T) { + tree := NewTree() + if tree.Size() != 0 { + t.Error("Expected empty tree size to be 0") + } + + tree.Set("key1", "value1") + tree.Set("key2", "value2") + if tree.Size() != 2 { + t.Error("Expected tree size to be 2") + } +} + +func TestTreeHas(t *testing.T) { + tree := NewTree() + tree.Set("key1", "value1") + + if !tree.Has("key1") { + t.Error("Expected tree to have key1") + } + + if tree.Has("key2") { + t.Error("Expected tree to not have key2") + } +} + +func TestTreeGet(t *testing.T) { + tree := NewTree() + tree.Set("key1", "value1") + + value, exists := tree.Get("key1") + if !exists || value != "value1" { + t.Error("Expected Get to return value1 and true") + } + + _, exists = tree.Get("key2") + if exists { + t.Error("Expected Get to return false for non-existent key") + } +} + +func TestTreeGetByIndex(t *testing.T) { + tree := NewTree() + tree.Set("key1", "value1") + tree.Set("key2", "value2") + + key, value := tree.GetByIndex(0) + if key != "key1" || value != "value1" { + t.Error("Expected GetByIndex(0) to return key1 and value1") + } + + key, value = tree.GetByIndex(1) + if key != "key2" || value != "value2" { + t.Error("Expected GetByIndex(1) to return key2 and value2") + } + + defer func() { + if r := recover(); r == nil { + t.Error("Expected GetByIndex to panic for out-of-range index") + } + }() + tree.GetByIndex(2) +} + +func TestTreeRemove(t *testing.T) { + tree := NewTree() + tree.Set("key1", "value1") + + value, removed := tree.Remove("key1") + if !removed || value != "value1" || tree.Size() != 0 { + t.Error("Expected Remove to remove key-value pair") + } + + _, removed = tree.Remove("key2") + if removed { + t.Error("Expected Remove to return false for non-existent key") + } +} + +func TestTreeIterate(t *testing.T) { + tree := NewTree() + tree.Set("key1", "value1") + tree.Set("key2", "value2") + tree.Set("key3", "value3") + + var keys []string + tree.Iterate("", "", func(key string, value interface{}) bool { + keys = append(keys, key) + return false + }) + + expectedKeys := []string{"key1", "key2", "key3"} + if !slicesEqual(keys, expectedKeys) { + t.Errorf("Expected keys %v, got %v", expectedKeys, keys) + } +} + +func TestTreeReverseIterate(t *testing.T) { + tree := NewTree() + tree.Set("key1", "value1") + tree.Set("key2", "value2") + tree.Set("key3", "value3") + + var keys []string + tree.ReverseIterate("", "", func(key string, value interface{}) bool { + keys = append(keys, key) + return false + }) + + expectedKeys := []string{"key3", "key2", "key1"} + if !slicesEqual(keys, expectedKeys) { + t.Errorf("Expected keys %v, got %v", expectedKeys, keys) + } +} + +func TestTreeIterateByOffset(t *testing.T) { + tree := NewTree() + tree.Set("key1", "value1") + tree.Set("key2", "value2") + tree.Set("key3", "value3") + + var keys []string + tree.IterateByOffset(1, 2, func(key string, value interface{}) bool { + keys = append(keys, key) + return false + }) + + expectedKeys := []string{"key2", "key3"} + if !slicesEqual(keys, expectedKeys) { + t.Errorf("Expected keys %v, got %v", expectedKeys, keys) + } +} + +func TestTreeReverseIterateByOffset(t *testing.T) { + tree := NewTree() + tree.Set("key1", "value1") + tree.Set("key2", "value2") + tree.Set("key3", "value3") + + var keys []string + tree.ReverseIterateByOffset(1, 2, func(key string, value interface{}) bool { + keys = append(keys, key) + return false + }) + + expectedKeys := []string{"key2", "key1"} + if !slicesEqual(keys, expectedKeys) { + t.Errorf("Expected keys %v, got %v", expectedKeys, keys) + } +} + +// Verify that Tree implements avl.ITree +// var _ avl.ITree = (*Tree)(nil) // TODO: fix gnovm bug: ./examples/gno.land/p/moul/cow: test pkg: panic: gno.land/p/moul/cow/tree_test.gno:166:5: name avl not defined in fileset with files [node.gno tree.gno node_test.gno tree_test.gno]: + +func TestCopyOnWrite(t *testing.T) { + // Create original tree + original := NewTree() + original.Set("A", 1) + original.Set("B", 2) + original.Set("C", 3) + + // Create a clone + clone := original.Clone() + + // Modify clone + clone.Set("B", 20) + clone.Set("D", 4) + + // Verify original is unchanged + if val, _ := original.Get("B"); val != 2 { + t.Errorf("Original tree was modified: expected B=2, got B=%v", val) + } + if original.Has("D") { + t.Error("Original tree was modified: found key D") + } + + // Verify clone has new values + if val, _ := clone.Get("B"); val != 20 { + t.Errorf("Clone not updated: expected B=20, got B=%v", val) + } + if val, _ := clone.Get("D"); val != 4 { + t.Errorf("Clone not updated: expected D=4, got D=%v", val) + } +} + +func TestCopyOnWriteEdgeCases(t *testing.T) { + t.Run("nil tree clone", func(t *testing.T) { + var original *Tree + clone := original.Clone() + if clone != nil { + t.Error("Expected nil clone from nil tree") + } + }) + + t.Run("empty tree clone", func(t *testing.T) { + original := NewTree() + clone := original.Clone() + + // Modify clone + clone.Set("A", 1) + + if original.Size() != 0 { + t.Error("Original empty tree was modified") + } + if clone.Size() != 1 { + t.Error("Clone was not modified") + } + }) + + t.Run("multiple clones", func(t *testing.T) { + original := NewTree() + original.Set("A", 1) + original.Set("B", 2) + + // Create multiple clones + clone1 := original.Clone() + clone2 := original.Clone() + clone3 := clone1.Clone() + + // Modify each clone differently + clone1.Set("A", 10) + clone2.Set("B", 20) + clone3.Set("C", 30) + + // Check original remains unchanged + if val, _ := original.Get("A"); val != 1 { + t.Errorf("Original modified: expected A=1, got A=%v", val) + } + if val, _ := original.Get("B"); val != 2 { + t.Errorf("Original modified: expected B=2, got B=%v", val) + } + + // Verify each clone has correct values + if val, _ := clone1.Get("A"); val != 10 { + t.Errorf("Clone1 incorrect: expected A=10, got A=%v", val) + } + if val, _ := clone2.Get("B"); val != 20 { + t.Errorf("Clone2 incorrect: expected B=20, got B=%v", val) + } + if val, _ := clone3.Get("C"); val != 30 { + t.Errorf("Clone3 incorrect: expected C=30, got C=%v", val) + } + }) + + t.Run("clone after removal", func(t *testing.T) { + original := NewTree() + original.Set("A", 1) + original.Set("B", 2) + original.Set("C", 3) + + // Remove a node and then clone + original.Remove("B") + clone := original.Clone() + + // Modify clone + clone.Set("B", 20) + + // Verify original state + if original.Has("B") { + t.Error("Original tree should not have key B") + } + + // Verify clone state + if val, _ := clone.Get("B"); val != 20 { + t.Errorf("Clone incorrect: expected B=20, got B=%v", val) + } + }) + + t.Run("concurrent modifications", func(t *testing.T) { + original := NewTree() + original.Set("A", 1) + original.Set("B", 2) + + clone1 := original.Clone() + clone2 := original.Clone() + + // Modify same key in different clones + clone1.Set("B", 20) + clone2.Set("B", 30) + + // Each clone should have its own value + if val, _ := clone1.Get("B"); val != 20 { + t.Errorf("Clone1 incorrect: expected B=20, got B=%v", val) + } + if val, _ := clone2.Get("B"); val != 30 { + t.Errorf("Clone2 incorrect: expected B=30, got B=%v", val) + } + }) + + t.Run("deep tree modifications", func(t *testing.T) { + original := NewTree() + // Create a deeper tree + keys := []string{"M", "F", "T", "B", "H", "P", "Z"} + for _, k := range keys { + original.Set(k, k) + } + + clone := original.Clone() + + // Modify a deep node + clone.Set("H", "modified") + + // Check original remains unchanged + if val, _ := original.Get("H"); val != "H" { + t.Errorf("Original modified: expected H='H', got H=%v", val) + } + + // Verify clone modification + if val, _ := clone.Get("H"); val != "modified" { + t.Errorf("Clone incorrect: expected H='modified', got H=%v", val) + } + }) + + t.Run("rebalancing test", func(t *testing.T) { + original := NewTree() + // Insert nodes that will cause rotations + keys := []string{"A", "B", "C", "D", "E"} + for _, k := range keys { + original.Set(k, k) + } + + clone := original.Clone() + + // Add more nodes to clone to trigger rebalancing + clone.Set("F", "F") + clone.Set("G", "G") + + // Verify original structure remains unchanged + originalKeys := collectKeys(original) + expectedOriginal := []string{"A", "B", "C", "D", "E"} + if !slicesEqual(originalKeys, expectedOriginal) { + t.Errorf("Original tree structure changed: got %v, want %v", originalKeys, expectedOriginal) + } + + // Verify clone has all keys + cloneKeys := collectKeys(clone) + expectedClone := []string{"A", "B", "C", "D", "E", "F", "G"} + if !slicesEqual(cloneKeys, expectedClone) { + t.Errorf("Clone tree structure incorrect: got %v, want %v", cloneKeys, expectedClone) + } + }) + + t.Run("value mutation test", func(t *testing.T) { + type MutableValue struct { + Data string + } + + original := NewTree() + mutable := &MutableValue{Data: "original"} + original.Set("key", mutable) + + clone := original.Clone() + + // Modify the mutable value + mutable.Data = "modified" + + // Both original and clone should see the modification + // because we're not deep copying values + origVal, _ := original.Get("key") + cloneVal, _ := clone.Get("key") + + if origVal.(*MutableValue).Data != "modified" { + t.Error("Original value not modified as expected") + } + if cloneVal.(*MutableValue).Data != "modified" { + t.Error("Clone value not modified as expected") + } + }) +} + +// Helper function to collect all keys in order +func collectKeys(tree *Tree) []string { + var keys []string + tree.Iterate("", "", func(key string, _ interface{}) bool { + keys = append(keys, key) + return false + }) + return keys +} diff --git a/examples/gno.land/p/sunspirit/md/gno.mod b/examples/gno.land/p/sunspirit/md/gno.mod new file mode 100644 index 00000000000..caee634f66f --- /dev/null +++ b/examples/gno.land/p/sunspirit/md/gno.mod @@ -0,0 +1 @@ +module gno.land/p/sunspirit/md diff --git a/examples/gno.land/p/sunspirit/md/md.gno b/examples/gno.land/p/sunspirit/md/md.gno new file mode 100644 index 00000000000..965373bee85 --- /dev/null +++ b/examples/gno.land/p/sunspirit/md/md.gno @@ -0,0 +1,179 @@ +package md + +import ( + "strings" + + "gno.land/p/demo/ufmt" +) + +// Builder helps to build a Markdown string from individual elements +type Builder struct { + elements []string +} + +// NewBuilder creates a new Builder instance +func NewBuilder() *Builder { + return &Builder{} +} + +// Add adds a Markdown element to the builder +func (m *Builder) Add(md ...string) *Builder { + m.elements = append(m.elements, md...) + return m +} + +// Render returns the final Markdown string joined with the specified separator +func (m *Builder) Render(separator string) string { + return strings.Join(m.elements, separator) +} + +// Bold returns bold text for markdown +func Bold(text string) string { + return ufmt.Sprintf("**%s**", text) +} + +// Italic returns italicized text for markdown +func Italic(text string) string { + return ufmt.Sprintf("*%s*", text) +} + +// Strikethrough returns strikethrough text for markdown +func Strikethrough(text string) string { + return ufmt.Sprintf("~~%s~~", text) +} + +// H1 returns a level 1 header for markdown +func H1(text string) string { + return ufmt.Sprintf("# %s\n", text) +} + +// H2 returns a level 2 header for markdown +func H2(text string) string { + return ufmt.Sprintf("## %s\n", text) +} + +// H3 returns a level 3 header for markdown +func H3(text string) string { + return ufmt.Sprintf("### %s\n", text) +} + +// H4 returns a level 4 header for markdown +func H4(text string) string { + return ufmt.Sprintf("#### %s\n", text) +} + +// H5 returns a level 5 header for markdown +func H5(text string) string { + return ufmt.Sprintf("##### %s\n", text) +} + +// H6 returns a level 6 header for markdown +func H6(text string) string { + return ufmt.Sprintf("###### %s\n", text) +} + +// BulletList returns an bullet list for markdown +func BulletList(items []string) string { + var sb strings.Builder + for _, item := range items { + sb.WriteString(ufmt.Sprintf("- %s\n", item)) + } + return sb.String() +} + +// OrderedList returns an ordered list for markdown +func OrderedList(items []string) string { + var sb strings.Builder + for i, item := range items { + sb.WriteString(ufmt.Sprintf("%d. %s\n", i+1, item)) + } + return sb.String() +} + +// TodoList returns a list of todo items with checkboxes for markdown +func TodoList(items []string, done []bool) string { + var sb strings.Builder + + for i, item := range items { + checkbox := " " + if done[i] { + checkbox = "x" + } + sb.WriteString(ufmt.Sprintf("- [%s] %s\n", checkbox, item)) + } + return sb.String() +} + +// Blockquote returns a blockquote for markdown +func Blockquote(text string) string { + lines := strings.Split(text, "\n") + var sb strings.Builder + for _, line := range lines { + sb.WriteString(ufmt.Sprintf("> %s\n", line)) + } + + return sb.String() +} + +// InlineCode returns inline code for markdown +func InlineCode(code string) string { + return ufmt.Sprintf("`%s`", code) +} + +// CodeBlock creates a markdown code block +func CodeBlock(content string) string { + return ufmt.Sprintf("```\n%s\n```", content) +} + +// LanguageCodeBlock creates a markdown code block with language-specific syntax highlighting +func LanguageCodeBlock(language, content string) string { + return ufmt.Sprintf("```%s\n%s\n```", language, content) +} + +// LineBreak returns the specified number of line breaks for markdown +func LineBreak(count uint) string { + if count > 0 { + return strings.Repeat("\n", int(count)+1) + } + return "" +} + +// HorizontalRule returns a horizontal rule for markdown +func HorizontalRule() string { + return "---\n" +} + +// Link returns a hyperlink for markdown +func Link(text, url string) string { + return ufmt.Sprintf("[%s](%s)", text, url) +} + +// Image returns an image for markdown +func Image(altText, url string) string { + return ufmt.Sprintf("![%s](%s)", altText, url) +} + +// Footnote returns a footnote for markdown +func Footnote(reference, text string) string { + return ufmt.Sprintf("[%s]: %s", reference, text) +} + +// Paragraph wraps the given text in a Markdown paragraph +func Paragraph(content string) string { + return ufmt.Sprintf("%s\n", content) +} + +// MdTable is an interface for table types that can be converted to Markdown format +type MdTable interface { + String() string +} + +// Table takes any MdTable implementation and returns its markdown representation +func Table(table MdTable) string { + return table.String() +} + +// EscapeMarkdown escapes special markdown characters in a string +func EscapeMarkdown(text string) string { + return ufmt.Sprintf("``%s``", text) +} diff --git a/examples/gno.land/p/sunspirit/md/md_test.gno b/examples/gno.land/p/sunspirit/md/md_test.gno new file mode 100644 index 00000000000..529cc2535bb --- /dev/null +++ b/examples/gno.land/p/sunspirit/md/md_test.gno @@ -0,0 +1,175 @@ +package md + +import ( + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/sunspirit/table" +) + +func TestNewBuilder(t *testing.T) { + mdBuilder := NewBuilder() + + uassert.Equal(t, len(mdBuilder.elements), 0, "Expected 0 elements") +} + +func TestAdd(t *testing.T) { + mdBuilder := NewBuilder() + + header := H1("Hi") + body := Paragraph("This is a test") + + mdBuilder.Add(header, body) + + uassert.Equal(t, len(mdBuilder.elements), 2, "Expected 2 element") + uassert.Equal(t, mdBuilder.elements[0], header, "Expected element %s, got %s", header, mdBuilder.elements[0]) + uassert.Equal(t, mdBuilder.elements[1], body, "Expected element %s, got %s", body, mdBuilder.elements[1]) +} + +func TestRender(t *testing.T) { + mdBuilder := NewBuilder() + + header := H1("Hello") + body := Paragraph("This is a test") + + seperator := "\n" + expected := header + seperator + body + + output := mdBuilder.Add(header, body).Render(seperator) + + uassert.Equal(t, output, expected, "Expected rendered string %s, got %s", expected, output) +} + +func Test_Bold(t *testing.T) { + uassert.Equal(t, Bold("Hello"), "**Hello**") +} + +func Test_Italic(t *testing.T) { + uassert.Equal(t, Italic("Hello"), "*Hello*") +} + +func Test_Strikethrough(t *testing.T) { + uassert.Equal(t, Strikethrough("Hello"), "~~Hello~~") +} + +func Test_H1(t *testing.T) { + uassert.Equal(t, H1("Header 1"), "# Header 1\n") +} + +func Test_H2(t *testing.T) { + uassert.Equal(t, H2("Header 2"), "## Header 2\n") +} + +func Test_H3(t *testing.T) { + uassert.Equal(t, H3("Header 3"), "### Header 3\n") +} + +func Test_H4(t *testing.T) { + uassert.Equal(t, H4("Header 4"), "#### Header 4\n") +} + +func Test_H5(t *testing.T) { + uassert.Equal(t, H5("Header 5"), "##### Header 5\n") +} + +func Test_H6(t *testing.T) { + uassert.Equal(t, H6("Header 6"), "###### Header 6\n") +} + +func Test_BulletList(t *testing.T) { + items := []string{"Item 1", "Item 2", "Item 3"} + result := BulletList(items) + expected := "- Item 1\n- Item 2\n- Item 3\n" + uassert.Equal(t, result, expected) +} + +func Test_OrderedList(t *testing.T) { + items := []string{"Item 1", "Item 2", "Item 3"} + result := OrderedList(items) + expected := "1. Item 1\n2. Item 2\n3. Item 3\n" + uassert.Equal(t, result, expected) +} + +func Test_TodoList(t *testing.T) { + items := []string{"Task 1", "Task 2"} + done := []bool{true, false} + result := TodoList(items, done) + expected := "- [x] Task 1\n- [ ] Task 2\n" + uassert.Equal(t, result, expected) +} + +func Test_Blockquote(t *testing.T) { + text := "This is a blockquote.\nIt has multiple lines." + result := Blockquote(text) + expected := "> This is a blockquote.\n> It has multiple lines.\n" + uassert.Equal(t, result, expected) +} + +func Test_InlineCode(t *testing.T) { + result := InlineCode("code") + uassert.Equal(t, result, "`code`") +} + +func Test_LanguageCodeBlock(t *testing.T) { + result := LanguageCodeBlock("python", "print('Hello')") + expected := "```python\nprint('Hello')\n```" + uassert.Equal(t, result, expected) +} + +func Test_CodeBlock(t *testing.T) { + result := CodeBlock("print('Hello')") + expected := "```\nprint('Hello')\n```" + uassert.Equal(t, result, expected) +} + +func Test_LineBreak(t *testing.T) { + result := LineBreak(2) + expected := "\n\n\n" + uassert.Equal(t, result, expected) + + result = LineBreak(0) + expected = "" + uassert.Equal(t, result, expected) +} + +func Test_HorizontalRule(t *testing.T) { + result := HorizontalRule() + uassert.Equal(t, result, "---\n") +} + +func Test_Link(t *testing.T) { + result := Link("Google", "http://google.com") + uassert.Equal(t, result, "[Google](http://google.com)") +} + +func Test_Image(t *testing.T) { + result := Image("Alt text", "http://image.url") + uassert.Equal(t, result, "![Alt text](http://image.url)") +} + +func Test_Footnote(t *testing.T) { + result := Footnote("1", "This is a footnote.") + uassert.Equal(t, result, "[1]: This is a footnote.") +} + +func Test_Paragraph(t *testing.T) { + result := Paragraph("This is a paragraph.") + uassert.Equal(t, result, "This is a paragraph.\n") +} + +func Test_Table(t *testing.T) { + tb, err := table.New([]string{"Header1", "Header2"}, [][]string{ + {"Row1Col1", "Row1Col2"}, + {"Row2Col1", "Row2Col2"}, + }) + uassert.NoError(t, err) + + result := Table(tb) + expected := "| Header1 | Header2 |\n| ---|---|\n| Row1Col1 | Row1Col2 |\n| Row2Col1 | Row2Col2 |\n" + uassert.Equal(t, result, expected) +} + +func Test_EscapeMarkdown(t *testing.T) { + result := EscapeMarkdown("- This is `code`") + uassert.Equal(t, result, "``- This is `code```") +} diff --git a/examples/gno.land/p/sunspirit/table/gno.mod b/examples/gno.land/p/sunspirit/table/gno.mod new file mode 100644 index 00000000000..1814c50b25d --- /dev/null +++ b/examples/gno.land/p/sunspirit/table/gno.mod @@ -0,0 +1 @@ +module gno.land/p/sunspirit/table diff --git a/examples/gno.land/p/sunspirit/table/table.gno b/examples/gno.land/p/sunspirit/table/table.gno new file mode 100644 index 00000000000..8c27516c962 --- /dev/null +++ b/examples/gno.land/p/sunspirit/table/table.gno @@ -0,0 +1,106 @@ +package table + +import ( + "strings" + + "gno.land/p/demo/ufmt" +) + +// Table defines the structure for a markdown table +type Table struct { + header []string + rows [][]string +} + +// Validate checks if the number of columns in each row matches the number of columns in the header +func (t *Table) Validate() error { + numCols := len(t.header) + for _, row := range t.rows { + if len(row) != numCols { + return ufmt.Errorf("row %v does not match header length %d", row, numCols) + } + } + return nil +} + +// New creates a new Table instance, ensuring the header and rows match in size +func New(header []string, rows [][]string) (*Table, error) { + t := &Table{ + header: header, + rows: rows, + } + + if err := t.Validate(); err != nil { + return nil, err + } + + return t, nil +} + +// Table returns a markdown string for the given Table +func (t *Table) String() string { + if err := t.Validate(); err != nil { + panic(err) + } + + var sb strings.Builder + + sb.WriteString("| " + strings.Join(t.header, " | ") + " |\n") + sb.WriteString("| " + strings.Repeat("---|", len(t.header)) + "\n") + + for _, row := range t.rows { + sb.WriteString("| " + strings.Join(row, " | ") + " |\n") + } + + return sb.String() +} + +// AddRow adds a new row to the table +func (t *Table) AddRow(row []string) error { + if len(row) != len(t.header) { + return ufmt.Errorf("row %v does not match header length %d", row, len(t.header)) + } + t.rows = append(t.rows, row) + return nil +} + +// AddColumn adds a new column to the table with the specified values +func (t *Table) AddColumn(header string, values []string) error { + if len(values) != len(t.rows) { + return ufmt.Errorf("values length %d does not match the number of rows %d", len(values), len(t.rows)) + } + + // Add the new header + t.header = append(t.header, header) + + // Add the new column values to each row + for i, value := range values { + t.rows[i] = append(t.rows[i], value) + } + return nil +} + +// RemoveRow removes a row from the table by its index +func (t *Table) RemoveRow(index int) error { + if index < 0 || index >= len(t.rows) { + return ufmt.Errorf("index %d is out of range", index) + } + t.rows = append(t.rows[:index], t.rows[index+1:]...) + return nil +} + +// RemoveColumn removes a column from the table by its index +func (t *Table) RemoveColumn(index int) error { + if index < 0 || index >= len(t.header) { + return ufmt.Errorf("index %d is out of range", index) + } + + // Remove the column from the header + t.header = append(t.header[:index], t.header[index+1:]...) + + // Remove the corresponding column from each row + for i := range t.rows { + t.rows[i] = append(t.rows[i][:index], t.rows[i][index+1:]...) + } + return nil +} diff --git a/examples/gno.land/p/sunspirit/table/table_test.gno b/examples/gno.land/p/sunspirit/table/table_test.gno new file mode 100644 index 00000000000..d4cd56ad0a8 --- /dev/null +++ b/examples/gno.land/p/sunspirit/table/table_test.gno @@ -0,0 +1,146 @@ +package table + +import ( + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestNew(t *testing.T) { + header := []string{"Name", "Age", "Country"} + rows := [][]string{ + {"Alice", "30", "USA"}, + {"Bob", "25", "UK"}, + } + + table, err := New(header, rows) + urequire.NoError(t, err) + + uassert.Equal(t, len(header), len(table.header)) + uassert.Equal(t, len(rows), len(table.rows)) +} + +func Test_AddRow(t *testing.T) { + header := []string{"Name", "Age"} + rows := [][]string{ + {"Alice", "30"}, + {"Bob", "25"}, + } + + table, err := New(header, rows) + urequire.NoError(t, err) + + // Add a valid row + err = table.AddRow([]string{"Charlie", "28"}) + urequire.NoError(t, err) + + expectedRows := [][]string{ + {"Alice", "30"}, + {"Bob", "25"}, + {"Charlie", "28"}, + } + uassert.Equal(t, len(expectedRows), len(table.rows)) + + // Attempt to add a row with a different number of columns + err = table.AddRow([]string{"David"}) + uassert.Error(t, err) +} + +func Test_AddColumn(t *testing.T) { + header := []string{"Name", "Age"} + rows := [][]string{ + {"Alice", "30"}, + {"Bob", "25"}, + } + + table, err := New(header, rows) + urequire.NoError(t, err) + + // Add a valid column + err = table.AddColumn("Country", []string{"USA", "UK"}) + urequire.NoError(t, err) + + expectedHeader := []string{"Name", "Age", "Country"} + expectedRows := [][]string{ + {"Alice", "30", "USA"}, + {"Bob", "25", "UK"}, + } + uassert.Equal(t, len(expectedHeader), len(table.header)) + uassert.Equal(t, len(expectedRows), len(table.rows)) + + // Attempt to add a column with a different number of values + err = table.AddColumn("City", []string{"New York"}) + uassert.Error(t, err) +} + +func Test_RemoveRow(t *testing.T) { + header := []string{"Name", "Age", "Country"} + rows := [][]string{ + {"Alice", "30", "USA"}, + {"Bob", "25", "UK"}, + } + + table, err := New(header, rows) + urequire.NoError(t, err) + + // Remove the first row + err = table.RemoveRow(0) + urequire.NoError(t, err) + + expectedRows := [][]string{ + {"Bob", "25", "UK"}, + } + uassert.Equal(t, len(expectedRows), len(table.rows)) + + // Attempt to remove a row out of range + err = table.RemoveRow(5) + uassert.Error(t, err) +} + +func Test_RemoveColumn(t *testing.T) { + header := []string{"Name", "Age", "Country"} + rows := [][]string{ + {"Alice", "30", "USA"}, + {"Bob", "25", "UK"}, + } + + table, err := New(header, rows) + urequire.NoError(t, err) + + // Remove the second column (Age) + err = table.RemoveColumn(1) + urequire.NoError(t, err) + + expectedHeader := []string{"Name", "Country"} + expectedRows := [][]string{ + {"Alice", "USA"}, + {"Bob", "UK"}, + } + uassert.Equal(t, len(expectedHeader), len(table.header)) + uassert.Equal(t, len(expectedRows), len(table.rows)) + + // Attempt to remove a column out of range + err = table.RemoveColumn(5) + uassert.Error(t, err) +} + +func Test_Validate(t *testing.T) { + header := []string{"Name", "Age", "Country"} + rows := [][]string{ + {"Alice", "30", "USA"}, + {"Bob", "25"}, + } + + table, err := New(header, rows[:1]) + urequire.NoError(t, err) + + // Validate should pass + err = table.Validate() + urequire.NoError(t, err) + + // Add an invalid row and validate again + table.rows = append(table.rows, rows[1]) + err = table.Validate() + uassert.Error(t, err) +} diff --git a/examples/gno.land/r/gov/dao/bridge/bridge.gno b/examples/gno.land/r/gov/dao/bridge/bridge.gno index ba47978f33f..73de21c4cfe 100644 --- a/examples/gno.land/r/gov/dao/bridge/bridge.gno +++ b/examples/gno.land/r/gov/dao/bridge/bridge.gno @@ -3,34 +3,60 @@ package bridge import ( "std" + "gno.land/p/demo/dao" "gno.land/p/demo/ownable" ) -const initialOwner = std.Address("g1u7y667z64x2h7vc6fmpcprgey4ck233jaww9zq") // @moul +const ( + initialOwner = std.Address("g1manfred47kzduec920z88wfr64ylksmdcedlf5") // @moul + loader = "gno.land/r/gov/dao/init" +) -var b *Bridge +var ( + b *Bridge + Ownable = ownable.NewWithAddress(initialOwner) +) // Bridge is the active GovDAO // implementation bridge type Bridge struct { - *ownable.Ownable - dao DAO } // init constructs the initial GovDAO implementation func init() { b = &Bridge{ - Ownable: ownable.NewWithAddress(initialOwner), - dao: &govdaoV2{}, + dao: nil, // initially set via r/gov/dao/init + } +} + +// LoadGovDAO loads the initial version of GovDAO into the bridge +// All changes to b.dao need to be done via GovDAO proposals after +func LoadGovDAO(d DAO) { + if std.PreviousRealm().PkgPath() != loader { + panic("unauthorized") } + + b.dao = d } -// SetDAO sets the currently active GovDAO implementation -func SetDAO(dao DAO) { - b.AssertCallerIsOwner() +// NewGovDAOImplChangeExecutor allows creating a GovDAO proposal +// Which will upgrade the GovDAO version inside the bridge +func NewGovDAOImplChangeExecutor(newImpl DAO) dao.Executor { + callback := func() error { + b.dao = newImpl + return nil + } + + return b.dao.NewGovDAOExecutor(callback) +} - b.dao = dao +// SetGovDAO allows the admin to set the GovDAO version manually +// This functionality can be fully disabled by Ownable.DropOwnership(), +// making this realm fully managed by GovDAO. +func SetGovDAO(d DAO) { + Ownable.AssertCallerIsOwner() + b.dao = d } // GovDAO returns the current GovDAO implementation diff --git a/examples/gno.land/r/gov/dao/bridge/bridge_test.gno b/examples/gno.land/r/gov/dao/bridge/bridge_test.gno index cff93fc497a..ba642141387 100644 --- a/examples/gno.land/r/gov/dao/bridge/bridge_test.gno +++ b/examples/gno.land/r/gov/dao/bridge/bridge_test.gno @@ -1,9 +1,8 @@ package bridge import ( - "testing" - "std" + "testing" "gno.land/p/demo/dao" "gno.land/p/demo/ownable" @@ -27,11 +26,47 @@ func TestBridge_DAO(t *testing.T) { uassert.Equal(t, proposalID, GovDAO().Propose(dao.ProposalRequest{})) } +func TestBridge_LoadGovDAO(t *testing.T) { + t.Run("invalid initializer path", func(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/demo/init")) // invalid loader + + // Attempt to set a new DAO implementation + uassert.PanicsWithMessage(t, "unauthorized", func() { + LoadGovDAO(&mockDAO{}) + }) + }) + + t.Run("valid loader", func(t *testing.T) { + var ( + initializer = "gno.land/r/gov/dao/init" + proposalID = uint64(10) + mockDAO = &mockDAO{ + proposeFn: func(_ dao.ProposalRequest) uint64 { + return proposalID + }, + } + ) + + std.TestSetRealm(std.NewCodeRealm(initializer)) + + // Attempt to set a new DAO implementation + uassert.NotPanics(t, func() { + LoadGovDAO(mockDAO) + }) + + uassert.Equal( + t, + mockDAO.Propose(dao.ProposalRequest{}), + GovDAO().Propose(dao.ProposalRequest{}), + ) + }) +} + func TestBridge_SetDAO(t *testing.T) { t.Run("invalid owner", func(t *testing.T) { // Attempt to set a new DAO implementation uassert.PanicsWithMessage(t, ownable.ErrUnauthorized.Error(), func() { - SetDAO(&mockDAO{}) + SetGovDAO(&mockDAO{}) }) }) @@ -49,10 +84,10 @@ func TestBridge_SetDAO(t *testing.T) { std.TestSetOriginCaller(addr) - b.Ownable = ownable.NewWithAddress(addr) + Ownable = ownable.NewWithAddress(addr) urequire.NotPanics(t, func() { - SetDAO(mockDAO) + SetGovDAO(mockDAO) }) uassert.Equal( diff --git a/examples/gno.land/r/gov/dao/bridge/v2.gno b/examples/gno.land/r/gov/dao/bridge/v2.gno deleted file mode 100644 index 216419cf31d..00000000000 --- a/examples/gno.land/r/gov/dao/bridge/v2.gno +++ /dev/null @@ -1,42 +0,0 @@ -package bridge - -import ( - "gno.land/p/demo/dao" - "gno.land/p/demo/membstore" - govdao "gno.land/r/gov/dao/v2" -) - -// govdaoV2 is a wrapper for interacting with the /r/gov/dao/v2 Realm -type govdaoV2 struct{} - -func (g *govdaoV2) Propose(request dao.ProposalRequest) uint64 { - return govdao.Propose(request) -} - -func (g *govdaoV2) VoteOnProposal(id uint64, option dao.VoteOption) { - govdao.VoteOnProposal(id, option) -} - -func (g *govdaoV2) ExecuteProposal(id uint64) { - govdao.ExecuteProposal(id) -} - -func (g *govdaoV2) GetPropStore() dao.PropStore { - return govdao.GetPropStore() -} - -func (g *govdaoV2) GetMembStore() membstore.MemberStore { - return govdao.GetMembStore() -} - -func (g *govdaoV2) NewGovDAOExecutor(cb func() error) dao.Executor { - return govdao.NewGovDAOExecutor(cb) -} - -func (g *govdaoV2) NewMemberPropExecutor(cb func() []membstore.Member) dao.Executor { - return govdao.NewMemberPropExecutor(cb) -} - -func (g *govdaoV2) NewMembStoreImplExecutor(cb func() membstore.MemberStore) dao.Executor { - return govdao.NewMembStoreImplExecutor(cb) -} diff --git a/examples/gno.land/r/gov/dao/init/gno.mod b/examples/gno.land/r/gov/dao/init/gno.mod new file mode 100644 index 00000000000..40541f4f152 --- /dev/null +++ b/examples/gno.land/r/gov/dao/init/gno.mod @@ -0,0 +1 @@ +module gno.land/r/gov/dao/init diff --git a/examples/gno.land/r/gov/dao/init/init.gno b/examples/gno.land/r/gov/dao/init/init.gno new file mode 100644 index 00000000000..39bdbedba83 --- /dev/null +++ b/examples/gno.land/r/gov/dao/init/init.gno @@ -0,0 +1,13 @@ +// Package init's only task is to load the initial GovDAO version into the bridge. +// This is done to avoid gov/dao/v2 as a bridge dependency, +// As this can often lead to cyclic dependency errors. +package init + +import ( + "gno.land/r/gov/dao/bridge" + govdao "gno.land/r/gov/dao/v2" +) + +func init() { + bridge.LoadGovDAO(govdao.GovDAO) +} diff --git a/examples/gno.land/r/gov/dao/v2/dao.gno b/examples/gno.land/r/gov/dao/v2/dao.gno index 5ee8e63236a..d69f9901301 100644 --- a/examples/gno.land/r/gov/dao/v2/dao.gno +++ b/examples/gno.land/r/gov/dao/v2/dao.gno @@ -11,8 +11,17 @@ import ( var ( d *simpledao.SimpleDAO // the current active DAO implementation members membstore.MemberStore // the member store + + // GovDAO exposes all functions of this contract as methods + GovDAO = &DAO{} ) +// DAO is an empty struct that allows all +// functions of this realm to be methods instead of functions +// This allows a registry, such as r/gov/dao/bridge +// to take this object and match it to a required interface +type DAO struct{} + const daoPkgPath = "gno.land/r/gov/dao/v2" func init() { @@ -33,7 +42,7 @@ func init() { // Propose is designed to be called by another contract or with // `maketx run`, not by a `maketx call`. -func Propose(request dao.ProposalRequest) uint64 { +func (_ DAO) Propose(request dao.ProposalRequest) uint64 { idx, err := d.Propose(request) if err != nil { panic(err) @@ -43,25 +52,25 @@ func Propose(request dao.ProposalRequest) uint64 { } // VoteOnProposal casts a vote for the given proposal -func VoteOnProposal(id uint64, option dao.VoteOption) { +func (_ DAO) VoteOnProposal(id uint64, option dao.VoteOption) { if err := d.VoteOnProposal(id, option); err != nil { panic(err) } } // ExecuteProposal executes the proposal -func ExecuteProposal(id uint64) { +func (_ DAO) ExecuteProposal(id uint64) { if err := d.ExecuteProposal(id); err != nil { panic(err) } } // GetPropStore returns the active proposal store -func GetPropStore() dao.PropStore { +func (_ DAO) GetPropStore() dao.PropStore { return d } // GetMembStore returns the active member store -func GetMembStore() membstore.MemberStore { +func (_ DAO) GetMembStore() membstore.MemberStore { return members } diff --git a/examples/gno.land/r/gov/dao/v2/poc.gno b/examples/gno.land/r/gov/dao/v2/poc.gno index 30d8a403f6e..81bdc7c9b12 100644 --- a/examples/gno.land/r/gov/dao/v2/poc.gno +++ b/examples/gno.land/r/gov/dao/v2/poc.gno @@ -13,7 +13,7 @@ import ( var errNoChangesProposed = errors.New("no set changes proposed") // NewGovDAOExecutor creates the govdao wrapped callback executor -func NewGovDAOExecutor(cb func() error) dao.Executor { +func (_ DAO) NewGovDAOExecutor(cb func() error) dao.Executor { if cb == nil { panic(errNoChangesProposed) } @@ -25,7 +25,7 @@ func NewGovDAOExecutor(cb func() error) dao.Executor { } // NewMemberPropExecutor returns the GOVDAO member change executor -func NewMemberPropExecutor(changesFn func() []membstore.Member) dao.Executor { +func (_ DAO) NewMemberPropExecutor(changesFn func() []membstore.Member) dao.Executor { if changesFn == nil { panic(errNoChangesProposed) } @@ -65,10 +65,10 @@ func NewMemberPropExecutor(changesFn func() []membstore.Member) dao.Executor { return errs } - return NewGovDAOExecutor(callback) + return GovDAO.NewGovDAOExecutor(callback) } -func NewMembStoreImplExecutor(changeFn func() membstore.MemberStore) dao.Executor { +func (_ DAO) NewMembStoreImplExecutor(changeFn func() membstore.MemberStore) dao.Executor { if changeFn == nil { panic(errNoChangesProposed) } @@ -79,7 +79,7 @@ func NewMembStoreImplExecutor(changeFn func() membstore.MemberStore) dao.Executo return nil } - return NewGovDAOExecutor(callback) + return GovDAO.NewGovDAOExecutor(callback) } // setMembStoreImpl sets a new dao.MembStore implementation diff --git a/examples/gno.land/r/gov/dao/v2/prop1_filetest.gno b/examples/gno.land/r/gov/dao/v2/prop1_filetest.gno index 7d8975e1fe8..c8ea983cc73 100644 --- a/examples/gno.land/r/gov/dao/v2/prop1_filetest.gno +++ b/examples/gno.land/r/gov/dao/v2/prop1_filetest.gno @@ -12,11 +12,13 @@ import ( "gno.land/p/demo/dao" pVals "gno.land/p/sys/validators" + _ "gno.land/r/gov/dao/init" // so that the govdao initializer is executed govdao "gno.land/r/gov/dao/v2" validators "gno.land/r/sys/validators/v2" ) func init() { + changesFn := func() []pVals.Validator { return []pVals.Validator{ { @@ -51,7 +53,7 @@ func init() { Executor: executor, } - govdao.Propose(prop) + govdao.GovDAO.Propose(prop) } func main() { @@ -60,13 +62,13 @@ func main() { println("--") println(govdao.Render("0")) println("--") - govdao.VoteOnProposal(0, dao.YesVote) + govdao.GovDAO.VoteOnProposal(0, dao.YesVote) println("--") println(govdao.Render("0")) println("--") println(validators.Render("")) println("--") - govdao.ExecuteProposal(0) + govdao.GovDAO.ExecuteProposal(0) println("--") println(govdao.Render("0")) println("--") diff --git a/examples/gno.land/r/gov/dao/v2/prop2_filetest.gno b/examples/gno.land/r/gov/dao/v2/prop2_filetest.gno index 84a64bc4ee2..f85373a471c 100644 --- a/examples/gno.land/r/gov/dao/v2/prop2_filetest.gno +++ b/examples/gno.land/r/gov/dao/v2/prop2_filetest.gno @@ -5,6 +5,7 @@ import ( "gno.land/p/demo/dao" gnoblog "gno.land/r/gnoland/blog" + _ "gno.land/r/gov/dao/init" // so that the govdao initializer is executed govdao "gno.land/r/gov/dao/v2" ) @@ -28,7 +29,7 @@ func init() { Executor: ex, } - govdao.Propose(prop) + govdao.GovDAO.Propose(prop) } func main() { @@ -37,13 +38,13 @@ func main() { println("--") println(govdao.Render("0")) println("--") - govdao.VoteOnProposal(0, "YES") + govdao.GovDAO.VoteOnProposal(0, "YES") println("--") println(govdao.Render("0")) println("--") println(gnoblog.Render("")) println("--") - govdao.ExecuteProposal(0) + govdao.GovDAO.ExecuteProposal(0) println("--") println(govdao.Render("0")) println("--") diff --git a/examples/gno.land/r/gov/dao/v2/prop3_filetest.gno b/examples/gno.land/r/gov/dao/v2/prop3_filetest.gno index 068f520e7e2..4032ba41d55 100644 --- a/examples/gno.land/r/gov/dao/v2/prop3_filetest.gno +++ b/examples/gno.land/r/gov/dao/v2/prop3_filetest.gno @@ -6,6 +6,7 @@ import ( "gno.land/p/demo/dao" "gno.land/p/demo/membstore" "gno.land/r/gov/dao/bridge" + _ "gno.land/r/gov/dao/init" // so that the govdao initializer is executed govdao "gno.land/r/gov/dao/v2" ) @@ -34,7 +35,7 @@ func init() { prop := dao.ProposalRequest{ Title: title, Description: description, - Executor: govdao.NewMemberPropExecutor(memberFn), + Executor: govdao.GovDAO.NewMemberPropExecutor(memberFn), } bridge.GovDAO().Propose(prop) @@ -42,25 +43,25 @@ func init() { func main() { println("--") - println(govdao.GetMembStore().Size()) + println(govdao.GovDAO.GetMembStore().Size()) println("--") println(govdao.Render("")) println("--") println(govdao.Render("0")) println("--") - govdao.VoteOnProposal(0, "YES") + govdao.GovDAO.VoteOnProposal(0, "YES") println("--") println(govdao.Render("0")) println("--") println(govdao.Render("")) println("--") - govdao.ExecuteProposal(0) + govdao.GovDAO.ExecuteProposal(0) println("--") println(govdao.Render("0")) println("--") println(govdao.Render("")) println("--") - println(govdao.GetMembStore().Size()) + println(govdao.GovDAO.GetMembStore().Size()) } // Output: diff --git a/examples/gno.land/r/gov/dao/v2/prop4_filetest.gno b/examples/gno.land/r/gov/dao/v2/prop4_filetest.gno index 13ca572c512..49326495dac 100644 --- a/examples/gno.land/r/gov/dao/v2/prop4_filetest.gno +++ b/examples/gno.land/r/gov/dao/v2/prop4_filetest.gno @@ -3,6 +3,7 @@ package main import ( "gno.land/p/demo/dao" "gno.land/r/gov/dao/bridge" + _ "gno.land/r/gov/dao/init" // so that the govdao initializer is executed govdaov2 "gno.land/r/gov/dao/v2" "gno.land/r/sys/params" ) diff --git a/examples/gno.land/r/jjoptimist/home/config.gno b/examples/gno.land/r/jjoptimist/home/config.gno new file mode 100644 index 00000000000..7f6ad955806 --- /dev/null +++ b/examples/gno.land/r/jjoptimist/home/config.gno @@ -0,0 +1,32 @@ +package home + +import ( + "std" + + "gno.land/p/demo/ownable" +) + +type Config struct { + Title string + Description string + Github string +} + +var config = Config{ + Title: "JJOptimist's Home Realm 🏠", + Description: "Exploring Gno and building on-chain", + Github: "jjoptimist", +} + +var Ownable = ownable.NewWithAddress(std.Address("g16vfw3r7zuz43fhky3xfsuc2hdv9tnhvlkyn0nj")) + +func GetConfig() Config { + return config +} + +func UpdateConfig(newTitle, newDescription, newGithub string) { + Ownable.AssertCallerIsOwner() + config.Title = newTitle + config.Description = newDescription + config.Github = newGithub +} diff --git a/examples/gno.land/r/jjoptimist/home/gno.mod b/examples/gno.land/r/jjoptimist/home/gno.mod new file mode 100644 index 00000000000..b4b591f6ab7 --- /dev/null +++ b/examples/gno.land/r/jjoptimist/home/gno.mod @@ -0,0 +1 @@ +module gno.land/r/jjoptimist/home diff --git a/examples/gno.land/r/jjoptimist/home/home.gno b/examples/gno.land/r/jjoptimist/home/home.gno new file mode 100644 index 00000000000..91a23670271 --- /dev/null +++ b/examples/gno.land/r/jjoptimist/home/home.gno @@ -0,0 +1,82 @@ +package home + +import ( + "std" + "strconv" + "time" + + "gno.land/r/leon/hof" +) + +const ( + gnomeArt1 = ` /\ + / \ + ,,,,, +(o.o) +(\_/) +-"-"-` + + gnomeArt2 = ` /\ + / \ + ,,,,, +(^.^) +(\_/) + -"-` + + gnomeArt3 = ` /\ + / \ + ,,,,, +(*.*) +(\_/) +"-"-"` + + gnomeArt4 = ` /\ + / \ + ,,,,, +(o.~) +(\_/) + -"-` +) + +var creation time.Time + +func getGnomeArt(height int64) string { + var art string + switch { + case height%7 == 0: + art = gnomeArt4 // winking gnome + case height%5 == 0: + art = gnomeArt3 // starry-eyed gnome + case height%3 == 0: + art = gnomeArt2 // happy gnome + default: + art = gnomeArt1 // regular gnome + } + return "```\n" + art + "\n```\n" +} + +func init() { + creation = time.Now() + hof.Register() +} + +func Render(path string) string { + height := std.GetHeight() + + output := "# " + config.Title + "\n\n" + + output += "## About Me\n" + output += "- 👋 Hi, I'm JJOptimist\n" + output += getGnomeArt(height) + output += "- 🌱 " + config.Description + "\n" + + output += "## Contact\n" + output += "- 📫 GitHub: [" + config.Github + "](https://github.com/" + config.Github + ")\n" + + output += "\n---\n" + output += "_Realm created: " + creation.Format("2006-01-02 15:04:05 UTC") + "_\n" + output += "_Owner: " + Ownable.Owner().String() + "_\n" + output += "_Current Block Height: " + strconv.Itoa(int(height)) + "_" + + return output +} diff --git a/examples/gno.land/r/jjoptimist/home/home_test.gno b/examples/gno.land/r/jjoptimist/home/home_test.gno new file mode 100644 index 00000000000..742204cca71 --- /dev/null +++ b/examples/gno.land/r/jjoptimist/home/home_test.gno @@ -0,0 +1,60 @@ +package home + +import ( + "strings" + "testing" +) + +func TestConfig(t *testing.T) { + cfg := GetConfig() + + if cfg.Title != "JJOptimist's Home Realm 🏠" { + t.Errorf("Expected title to be 'JJOptimist's Home Realm 🏠', got %s", cfg.Title) + } + if cfg.Description != "Exploring Gno and building on-chain" { + t.Errorf("Expected description to be 'Exploring Gno and building on-chain', got %s", cfg.Description) + } + if cfg.Github != "jjoptimist" { + t.Errorf("Expected github to be 'jjoptimist', got %s", cfg.Github) + } +} + +func TestRender(t *testing.T) { + output := Render("") + + // Test that required sections are present + if !strings.Contains(output, "# "+config.Title) { + t.Error("Rendered output missing title") + } + if !strings.Contains(output, "## About Me") { + t.Error("Rendered output missing About Me section") + } + if !strings.Contains(output, "## Contact") { + t.Error("Rendered output missing Contact section") + } + if !strings.Contains(output, config.Description) { + t.Error("Rendered output missing description") + } + if !strings.Contains(output, config.Github) { + t.Error("Rendered output missing github link") + } +} + +func TestGetGnomeArt(t *testing.T) { + tests := []struct { + height int64 + expected string + }{ + {7, gnomeArt4}, // height divisible by 7 + {5, gnomeArt3}, // height divisible by 5 + {3, gnomeArt2}, // height divisible by 3 + {2, gnomeArt1}, // default case + } + + for _, tt := range tests { + art := getGnomeArt(tt.height) + if !strings.Contains(art, tt.expected) { + t.Errorf("For height %d, expected art containing %s, got %s", tt.height, tt.expected, art) + } + } +} diff --git a/examples/gno.land/r/moul/microposts/README.md b/examples/gno.land/r/moul/microposts/README.md new file mode 100644 index 00000000000..5c7763020cd --- /dev/null +++ b/examples/gno.land/r/moul/microposts/README.md @@ -0,0 +1,5 @@ +# fork of `leon/fosdem25/microposts` + +removing optional lines to make the code more concise for slides. + +Original work here: https://gno.land/r/leon/fosdem25/microposts diff --git a/examples/gno.land/r/moul/microposts/gno.mod b/examples/gno.land/r/moul/microposts/gno.mod new file mode 100644 index 00000000000..00386f6e856 --- /dev/null +++ b/examples/gno.land/r/moul/microposts/gno.mod @@ -0,0 +1 @@ +module gno.land/r/moul/microposts diff --git a/examples/gno.land/r/moul/microposts/microposts_test.gno b/examples/gno.land/r/moul/microposts/microposts_test.gno new file mode 100644 index 00000000000..61929081e34 --- /dev/null +++ b/examples/gno.land/r/moul/microposts/microposts_test.gno @@ -0,0 +1,3 @@ +package microposts + +// empty file just to make sure that `gno test` tries to parse the implementation. diff --git a/examples/gno.land/r/moul/microposts/post.gno b/examples/gno.land/r/moul/microposts/post.gno new file mode 100644 index 00000000000..0832d8ac3c6 --- /dev/null +++ b/examples/gno.land/r/moul/microposts/post.gno @@ -0,0 +1,18 @@ +package microposts + +import ( + "std" + "time" +) + +type Post struct { + text string + author std.Address + createdAt time.Time +} + +func (p Post) String() string { + out := p.text + "\n" + out += "_" + p.createdAt.Format("02 Jan 2006, 15:04") + ", by " + p.author.String() + "_" + return out +} diff --git a/examples/gno.land/r/moul/microposts/realm.gno b/examples/gno.land/r/moul/microposts/realm.gno new file mode 100644 index 00000000000..a03b6dd958b --- /dev/null +++ b/examples/gno.land/r/moul/microposts/realm.gno @@ -0,0 +1,25 @@ +package microposts + +import ( + "std" + "strconv" + "time" +) + +var posts []*Post + +func CreatePost(text string) { + posts = append(posts, &Post{ + text: text, + author: std.PrevRealm().Addr(), // provided by env + createdAt: time.Now(), + }) +} + +func Render(_ string) string { + out := "# Posts\n" + for i := len(posts) - 1; i >= 0; i-- { + out += "### Post " + strconv.Itoa(i) + "\n" + posts[i].String() + } + return out +} diff --git a/examples/gno.land/r/moul/present/present.gno b/examples/gno.land/r/moul/present/present.gno new file mode 100644 index 00000000000..c9f4f1b3a33 --- /dev/null +++ b/examples/gno.land/r/moul/present/present.gno @@ -0,0 +1,353 @@ +package present + +import ( + "net/url" + "std" + "strconv" + "strings" + "time" + + "gno.land/p/demo/avl/pager" + "gno.land/p/demo/ownable" + "gno.land/p/demo/seqid" + "gno.land/p/demo/ufmt" + "gno.land/p/moul/collection" + "gno.land/p/moul/md" + "gno.land/p/moul/mdtable" + "gno.land/p/moul/realmpath" + "gno.land/p/moul/txlink" +) + +type Presentation struct { + Slug string + Title string + Event string + Author string + Uploader std.Address + Date time.Time + Content string + EditDate time.Time + NumSlides int +} + +var ( + presentations *collection.Collection + Ownable *ownable.Ownable +) + +func init() { + presentations = collection.New() + // for /view and /slides + presentations.AddIndex("slug", func(v interface{}) string { + return v.(*Presentation).Slug + }, collection.UniqueIndex) + + // for table sorting + presentations.AddIndex("date", func(v interface{}) string { + return v.(*Presentation).Date.String() + }, collection.DefaultIndex) + presentations.AddIndex("author", func(v interface{}) string { + return v.(*Presentation).Author + }, collection.DefaultIndex) + presentations.AddIndex("title", func(v interface{}) string { + return v.(*Presentation).Title + }, collection.DefaultIndex) + + Ownable = ownable.New() +} + +// Render handles the realm's rendering logic +func Render(path string) string { + req := realmpath.Parse(path) + + // Get slug from path + slug := req.PathPart(0) + + // List view (home) + if slug == "" { + return renderList(req) + } + + // Slides view + if req.PathPart(1) == "slides" { + page := 1 + if pageStr := req.Query.Get("page"); pageStr != "" { + var err error + page, err = strconv.Atoi(pageStr) + if err != nil { + return "400: invalid page number" + } + } + return renderSlides(slug, page) + } + + // Regular view + return renderView(slug) +} + +// Set adds or updates a presentation +func Set(slug, title, event, author, date, content string) string { + Ownable.AssertCallerIsOwner() + + parsedDate, err := time.Parse("2006-01-02", date) + if err != nil { + return "400: invalid date format (expected: YYYY-MM-DD)" + } + + numSlides := 1 // Count intro slide + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "## ") { + numSlides++ + } + } + numSlides++ // Count thank you slide + + p := &Presentation{ + Slug: slug, + Title: title, + Event: event, + Author: author, + Uploader: std.PreviousRealm().Address(), + Date: parsedDate, + Content: content, + EditDate: time.Now(), + NumSlides: numSlides, + } + + presentations.Set(p) + return "presentation saved successfully" +} + +// Delete removes a presentation +func Delete(slug string) string { + Ownable.AssertCallerIsOwner() + + entry := presentations.GetFirst("slug", slug) + if entry == nil { + return "404: presentation not found" + } + + // XXX: consider this: + // if entry.Obj.(*Presentation).Uploader != std.PrevRealm().Addr() { + // return "401: unauthorized - only the uploader can delete their presentations" + // } + + // Convert the entry's ID from string to uint64 and delete + numericID, err := seqid.FromString(entry.ID) + if err != nil { + return "500: invalid entry ID format" + } + + presentations.Delete(uint64(numericID)) + return "presentation deleted successfully" +} + +func renderList(req *realmpath.Request) string { + var out strings.Builder + out.WriteString(md.H1("Presentations")) + + // Setup pager + index := presentations.GetIndex(getSortField(req)) + pgr := pager.NewPager(index, 10, isSortReversed(req)) + + // Get current page + page := pgr.MustGetPageByPath(req.String()) + + // Create table + dateColumn := renderSortLink(req, "date", "Date") + titleColumn := renderSortLink(req, "title", "Title") + authorColumn := renderSortLink(req, "author", "Author") + table := mdtable.Table{ + Headers: []string{dateColumn, titleColumn, "Event", authorColumn, "Slides"}, + } + + // Add rows from current page + for _, item := range page.Items { + // Get the actual presentation using the ID from the index + // XXX: improve p/moul/collection to make this more convenient. + // - no need to make per-id lookup. + // - transparently support multi-values. + // - integrate a sortable pager? + var ids []string + if ids_, ok := item.Value.([]string); ok { + ids = ids_ + } else if id, ok := item.Value.(string); ok { + ids = []string{id} + } + + for _, id := range ids { + entry := presentations.GetFirst(collection.IDIndex, id) + if entry == nil { + continue + } + p := entry.Obj.(*Presentation) + + table.Append([]string{ + p.Date.Format("2006-01-02"), + md.Link(p.Title, localPath(p.Slug, nil)), + p.Event, + p.Author, + ufmt.Sprintf("%d", p.NumSlides), + }) + } + } + + out.WriteString(table.String()) + out.WriteString(page.Picker()) // XXX: picker is not preserving the previous flags, should take "req" as argument. + return out.String() +} + +func (p *Presentation) FirstSlide() string { + var out strings.Builder + out.WriteString(md.H1(p.Title)) + out.WriteString(md.Paragraph(md.Bold(p.Event) + ", " + p.Date.Format("2 Jan 2006"))) + out.WriteString(md.Paragraph("by " + md.Bold(p.Author))) // XXX: link to u/? + return out.String() +} + +func (p *Presentation) LastSlide() string { + var out strings.Builder + out.WriteString(md.H1(p.Title)) + out.WriteString(md.H2("Thank You!")) + out.WriteString(md.Paragraph(p.Author)) + fullPath := "https://" + std.GetChainDomain() + localPath(p.Slug, nil) + out.WriteString(md.Paragraph("🔗 " + md.Link(fullPath, fullPath))) + // XXX: QRCode + return out.String() +} + +func renderView(slug string) string { + if slug == "" { + return "400: missing presentation slug" + } + + entry := presentations.GetFirst("slug", slug) + if entry == nil { + return "404: presentation not found" + } + + p := entry.Obj.(*Presentation) + var out strings.Builder + + // Header using FirstSlide helper + out.WriteString(p.FirstSlide()) + + // Slide mode link + out.WriteString(md.Link("View as slides", localPath(p.Slug+"/slides", nil)) + "\n\n") + out.WriteString(md.HorizontalRule()) + out.WriteString(md.Paragraph(p.Content)) + + // Metadata footer + out.WriteString(md.HorizontalRule()) + out.WriteString(ufmt.Sprintf("Last edited: %s\n\n", p.EditDate.Format("2006-01-02 15:04:05"))) + out.WriteString(ufmt.Sprintf("Uploader: `%s`\n\n", p.Uploader)) + out.WriteString(ufmt.Sprintf("Number of slides: %d\n\n", p.NumSlides)) + + // Admin actions + // XXX: consider a dynamic toggle for admin actions + editLink := txlink.Call("Set", + "slug", p.Slug, + "title", p.Title, + "author", p.Author, + "event", p.Event, + "date", p.Date.Format("2006-01-02"), + ) + deleteLink := txlink.Call("Delete", "slug", p.Slug) + out.WriteString(md.Paragraph(md.Link("Edit", editLink) + " | " + md.Link("Delete", deleteLink))) + + return out.String() +} + +// renderSlidesNavigation returns the navigation bar for slides +func renderSlidesNavigation(slug string, currentPage, totalSlides int) string { + var out strings.Builder + if currentPage > 1 { + prevLink := localPath(slug+"/slides", url.Values{"page": {ufmt.Sprintf("%d", currentPage-1)}}) + out.WriteString(md.Link("← Prev", prevLink) + " ") + } + out.WriteString(ufmt.Sprintf("| %d/%d |", currentPage, totalSlides)) + if currentPage < totalSlides { + nextLink := localPath(slug+"/slides", url.Values{"page": {ufmt.Sprintf("%d", currentPage+1)}}) + out.WriteString(" " + md.Link("Next →", nextLink)) + } + return md.Paragraph(out.String()) +} + +func renderSlides(slug string, currentPage int) string { + if slug == "" { + return "400: missing presentation ID" + } + + entry := presentations.GetFirst("slug", slug) + if entry == nil { + return "404: presentation not found" + } + + p := entry.Obj.(*Presentation) + slides := strings.Split("\n"+p.Content, "\n## ") + if currentPage < 1 || currentPage > p.NumSlides { + return "404: invalid slide number" + } + + var out strings.Builder + + // Display current slide + if currentPage == 1 { + out.WriteString(p.FirstSlide()) + } else if currentPage == p.NumSlides { + out.WriteString(p.LastSlide()) + } else { + out.WriteString(md.H1(p.Title)) + out.WriteString("## " + slides[currentPage-1] + "\n\n") + } + + out.WriteString(renderSlidesNavigation(slug, currentPage, p.NumSlides)) + return out.String() +} + +// Helper functions for sorting and pagination +func getSortField(req *realmpath.Request) string { + field := req.Query.Get("sort") + switch field { + case "date", "slug", "author", "title": + return field + } + return "date" +} + +func isSortReversed(req *realmpath.Request) bool { + return req.Query.Get("order") != "asc" +} + +func renderSortLink(req *realmpath.Request, field, label string) string { + currentField := getSortField(req) + currentOrder := req.Query.Get("order") + + newOrder := "desc" + if field == currentField && currentOrder != "asc" { + newOrder = "asc" + } + + query := req.Query + query.Set("sort", field) + query.Set("order", newOrder) + + if field == currentField { + if newOrder == "asc" { + label += " ↑" + } else { + label += " ↓" + } + } + + return md.Link(label, "?"+query.Encode()) +} + +// helper to create local realm links +func localPath(path string, query url.Values) string { + req := &realmpath.Request{ + Path: path, + Query: query, + } + return req.String() +} diff --git a/examples/gno.land/r/moul/present/present_filetest.gno b/examples/gno.land/r/moul/present/present_filetest.gno new file mode 100644 index 00000000000..7e9385454b9 --- /dev/null +++ b/examples/gno.land/r/moul/present/present_filetest.gno @@ -0,0 +1,233 @@ +package main + +import ( + "gno.land/r/moul/present" +) + +func main() { + // Cleanup initial state + ret := present.Delete("demo") + if ret != "presentation deleted successfully" { + panic("internal error") + } + + // Create presentations with IDs from 10-20 + presentations := []struct { + id string + title string + event string + author string + date string + content string + }{ + {"s10", "title10", "event3", "author1", "2024-01-01", "## s10.0\n## s10.1"}, + {"s11", "title11", "event1", "author2", "2024-01-15", "## s11.0\n## s11.1"}, + {"s12", "title12", "event2", "author1", "2024-02-01", "## s12.0\n## s12.1"}, + {"s13", "title13", "event1", "author3", "2024-01-20", "## s13.0\n## s13.1"}, + {"s14", "title14", "event3", "author2", "2024-03-01", "## s14.0\n## s14.1"}, + {"s15", "title15", "event2", "author1", "2024-02-15", "## s15.0\n## s15.1\n## s15.2"}, + {"s16", "title16", "event1", "author4", "2024-03-15", "## s16.0\n## s16.1"}, + {"s17", "title17", "event3", "author2", "2024-01-10", "## s17.0\n## s17.1"}, + {"s18", "title18", "event2", "author3", "2024-02-20", "## s18.0\n## s18.1"}, + {"s19", "title19", "event1", "author1", "2024-03-10", "## s19.0\n## s19.1"}, + {"s20", "title20", "event3", "author4", "2024-01-05", "## s20.0\n## s20.1"}, + } + + for _, p := range presentations { + result := present.Set(p.id, p.title, p.event, p.author, p.date, p.content) + if result != "presentation saved successfully" { + panic("failed to add presentation: " + result) + } + } + + // Test different sorting scenarios + printRender("") // default + printRender("?order=asc&sort=date") // by date ascending + printRender("?order=asc&sort=title") // by title ascending + printRender("?order=asc&sort=author") // by author ascending (multiple entries per author) + + // Test pagination + printRender("?order=asc&sort=title&page=2") // second page + + // Test view + printRender("s15") // view by slug + + // Test slides + printRender("s15/slides") // slides by slug + printRender("s15/slides?page=2") // slides by slug, second page + printRender("s15/slides?page=3") // slides by slug, third page + printRender("s15/slides?page=4") // slides by slug, fourth page + printRender("s15/slides?page=5") // slides by slug, fifth page +} + +// Helper function to print path and render result +func printRender(path string) { + println("+-------------------------------") + println("| PATH:", path) + println("| RESULT:\n" + present.Render(path) + "\n") +} + +// Output: +// +------------------------------- +// | PATH: +// | RESULT: +// # Presentations +// | [Date ↑](?order=asc&sort=date) | [Title](?order=desc&sort=title) | Event | [Author](?order=desc&sort=author) | Slides | +// | --- | --- | --- | --- | --- | +// | 2024-03-15 | [title16](/r/moul/present:s16) | event1 | author4 | 4 | +// | 2024-03-10 | [title19](/r/moul/present:s19) | event1 | author1 | 4 | +// | 2024-03-01 | [title14](/r/moul/present:s14) | event3 | author2 | 4 | +// | 2024-02-20 | [title18](/r/moul/present:s18) | event2 | author3 | 4 | +// | 2024-02-15 | [title15](/r/moul/present:s15) | event2 | author1 | 5 | +// | 2024-02-01 | [title12](/r/moul/present:s12) | event2 | author1 | 4 | +// | 2024-01-20 | [title13](/r/moul/present:s13) | event1 | author3 | 4 | +// | 2024-01-15 | [title11](/r/moul/present:s11) | event1 | author2 | 4 | +// | 2024-01-10 | [title17](/r/moul/present:s17) | event3 | author2 | 4 | +// | 2024-01-05 | [title20](/r/moul/present:s20) | event3 | author4 | 4 | +// **1** | [2](?page=2) +// +// +------------------------------- +// | PATH: ?order=asc&sort=date +// | RESULT: +// # Presentations +// | [Date ↓](?order=desc&sort=date) | [Title](?order=desc&sort=title) | Event | [Author](?order=desc&sort=author) | Slides | +// | --- | --- | --- | --- | --- | +// | 2024-01-01 | [title10](/r/moul/present:s10) | event3 | author1 | 4 | +// | 2024-01-05 | [title20](/r/moul/present:s20) | event3 | author4 | 4 | +// | 2024-01-10 | [title17](/r/moul/present:s17) | event3 | author2 | 4 | +// | 2024-01-15 | [title11](/r/moul/present:s11) | event1 | author2 | 4 | +// | 2024-01-20 | [title13](/r/moul/present:s13) | event1 | author3 | 4 | +// | 2024-02-01 | [title12](/r/moul/present:s12) | event2 | author1 | 4 | +// | 2024-02-15 | [title15](/r/moul/present:s15) | event2 | author1 | 5 | +// | 2024-02-20 | [title18](/r/moul/present:s18) | event2 | author3 | 4 | +// | 2024-03-01 | [title14](/r/moul/present:s14) | event3 | author2 | 4 | +// | 2024-03-10 | [title19](/r/moul/present:s19) | event1 | author1 | 4 | +// **1** | [2](?page=2) +// +// +------------------------------- +// | PATH: ?order=asc&sort=title +// | RESULT: +// # Presentations +// | [Date](?order=desc&sort=date) | [Title](?order=desc&sort=title) | Event | [Author](?order=desc&sort=author) | Slides | +// | --- | --- | --- | --- | --- | +// | 2024-01-01 | [title10](/r/moul/present:s10) | event3 | author1 | 4 | +// | 2024-01-15 | [title11](/r/moul/present:s11) | event1 | author2 | 4 | +// | 2024-02-01 | [title12](/r/moul/present:s12) | event2 | author1 | 4 | +// | 2024-01-20 | [title13](/r/moul/present:s13) | event1 | author3 | 4 | +// | 2024-03-01 | [title14](/r/moul/present:s14) | event3 | author2 | 4 | +// | 2024-02-15 | [title15](/r/moul/present:s15) | event2 | author1 | 5 | +// | 2024-03-15 | [title16](/r/moul/present:s16) | event1 | author4 | 4 | +// | 2024-01-10 | [title17](/r/moul/present:s17) | event3 | author2 | 4 | +// | 2024-02-20 | [title18](/r/moul/present:s18) | event2 | author3 | 4 | +// | 2024-03-10 | [title19](/r/moul/present:s19) | event1 | author1 | 4 | +// **1** | [2](?page=2) +// +// +------------------------------- +// | PATH: ?order=asc&sort=author +// | RESULT: +// # Presentations +// | [Date](?order=desc&sort=date) | [Title](?order=desc&sort=title) | Event | [Author](?order=desc&sort=author) | Slides | +// | --- | --- | --- | --- | --- | +// | 2024-01-01 | [title10](/r/moul/present:s10) | event3 | author1 | 4 | +// | 2024-02-01 | [title12](/r/moul/present:s12) | event2 | author1 | 4 | +// | 2024-02-15 | [title15](/r/moul/present:s15) | event2 | author1 | 5 | +// | 2024-03-10 | [title19](/r/moul/present:s19) | event1 | author1 | 4 | +// | 2024-01-15 | [title11](/r/moul/present:s11) | event1 | author2 | 4 | +// | 2024-03-01 | [title14](/r/moul/present:s14) | event3 | author2 | 4 | +// | 2024-01-10 | [title17](/r/moul/present:s17) | event3 | author2 | 4 | +// | 2024-01-20 | [title13](/r/moul/present:s13) | event1 | author3 | 4 | +// | 2024-02-20 | [title18](/r/moul/present:s18) | event2 | author3 | 4 | +// | 2024-03-15 | [title16](/r/moul/present:s16) | event1 | author4 | 4 | +// | 2024-01-05 | [title20](/r/moul/present:s20) | event3 | author4 | 4 | +// +// +// +------------------------------- +// | PATH: ?order=asc&sort=title&page=2 +// | RESULT: +// # Presentations +// | [Date](?order=desc&page=2&sort=date) | [Title](?order=desc&page=2&sort=title) | Event | [Author](?order=desc&page=2&sort=author) | Slides | +// | --- | --- | --- | --- | --- | +// | 2024-01-05 | [title20](/r/moul/present:s20) | event3 | author4 | 4 | +// [1](?page=1) | **2** +// +// +------------------------------- +// | PATH: s15 +// | RESULT: +// # title15 +// **event2**, 15 Feb 2024 +// +// by **author1** +// +// [View as slides](/r/moul/present:s15/slides) +// +// --- +// ## s15.0 +// ## s15.1 +// ## s15.2 +// +// --- +// Last edited: 2009-02-13 23:31:30 +// +// Uploader: `g1wymu47drhr0kuq2098m792lytgtj2nyx77yrsm` +// +// Number of slides: 5 +// +// [Edit](/r/moul/present$help&func=Set&author=author1&date=2024-02-15&event=event2&slug=s15&title=title15) | [Delete](/r/moul/present$help&func=Delete&slug=s15) +// +// +// +// +------------------------------- +// | PATH: s15/slides +// | RESULT: +// # title15 +// **event2**, 15 Feb 2024 +// +// by **author1** +// +// | 1/5 | [Next →](/r/moul/present:s15/slides?page=2) +// +// +// +// +------------------------------- +// | PATH: s15/slides?page=2 +// | RESULT: +// # title15 +// ## s15.0 +// +// [← Prev](/r/moul/present:s15/slides?page=1) | 2/5 | [Next →](/r/moul/present:s15/slides?page=3) +// +// +// +// +------------------------------- +// | PATH: s15/slides?page=3 +// | RESULT: +// # title15 +// ## s15.1 +// +// [← Prev](/r/moul/present:s15/slides?page=2) | 3/5 | [Next →](/r/moul/present:s15/slides?page=4) +// +// +// +// +------------------------------- +// | PATH: s15/slides?page=4 +// | RESULT: +// # title15 +// ## s15.2 +// +// [← Prev](/r/moul/present:s15/slides?page=3) | 4/5 | [Next →](/r/moul/present:s15/slides?page=5) +// +// +// +// +------------------------------- +// | PATH: s15/slides?page=5 +// | RESULT: +// # title15 +// ## Thank You! +// author1 +// +// 🔗 [https://tests\.gno\.land/r/moul/present:s15](https://tests.gno.land/r/moul/present:s15) +// +// [← Prev](/r/moul/present:s15/slides?page=4) | 5/5 | +// +// +// diff --git a/examples/gno.land/r/moul/present/present_init.gno b/examples/gno.land/r/moul/present/present_init.gno new file mode 100644 index 00000000000..b103bdf8cd6 --- /dev/null +++ b/examples/gno.land/r/moul/present/present_init.gno @@ -0,0 +1,25 @@ +package present + +func init() { + _ = Set( + "demo", // id + "Demo Slides", // title + "Demo Event", // event + "@demo", // author + "2025-02-02", // date + `## Slide One +- Point A +- Point B +- Point C + +## Slide Two +- Feature 1 +- Feature 2 +- Feature 3 + +## Slide Three +- Next step +- Another step +- Final step`, + ) +} diff --git a/examples/gno.land/r/moul/present/present_miami23.gno b/examples/gno.land/r/moul/present/present_miami23.gno deleted file mode 100644 index ca2160de3a9..00000000000 --- a/examples/gno.land/r/moul/present/present_miami23.gno +++ /dev/null @@ -1,42 +0,0 @@ -package present - -func init() { - path := "miami23" - title := "Portal Loop Demo (Miami 2023)" - body := ` -Rendered by Gno. - -[Source (WIP)](https://github.com/gnolang/gno/pull/1176) - -## Portal Loop - -- DONE: Dynamic homepage, key pages, aliases, and redirects. -- TODO: Deploy with history, complete worxdao v0. -- Will replace the static gno.land site. -- Enhances local development. - -[GitHub Issue](https://github.com/gnolang/gno/issues/1108) - -## Roadmap - -- Crafting the roadmap this week, open to collaboration. -- Combining onchain (portal loop) and offchain (GitHub). -- Next week: Unveiling the official v0 roadmap. - -## Teams, DAOs, Projects - -- Developing worxDAO contracts for directories of projects and teams. -- GitHub teams and projects align with this structure. -- CODEOWNER file updates coming. -- Initial teams announced next week. - -## Tech Team Retreat Plan - -- Continue Portal Loop. -- Consider dApp development. -- Explore new topics [here](https://github.com/orgs/gnolang/projects/15/). -- Engage in workshops. -- Connect and have fun with colleagues. -` - _ = b.NewPost(adminAddr, path, title, body, "2023-10-15T13:17:24Z", []string{"moul"}, []string{"demo", "portal-loop", "miami"}) -} diff --git a/examples/gno.land/r/moul/present/present_miami23_filetest.gno b/examples/gno.land/r/moul/present/present_miami23_filetest.gno deleted file mode 100644 index 09d332ec6e4..00000000000 --- a/examples/gno.land/r/moul/present/present_miami23_filetest.gno +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "gno.land/r/moul/present" -) - -func main() { - println(present.Render("")) - println("------------------------------------") - println(present.Render("p/miami23")) -} diff --git a/examples/gno.land/r/moul/present/presentations.gno b/examples/gno.land/r/moul/present/presentations.gno deleted file mode 100644 index c5529804751..00000000000 --- a/examples/gno.land/r/moul/present/presentations.gno +++ /dev/null @@ -1,17 +0,0 @@ -package present - -import ( - "gno.land/p/demo/blog" -) - -// TODO: switch from p/blog to p/present - -var b = &blog.Blog{ - Title: "Manfred's Presentations", - Prefix: "/r/moul/present:", - NoBreadcrumb: true, -} - -func Render(path string) string { - return b.Render(path) -} diff --git a/examples/gno.land/r/sunspirit/home/gno.mod b/examples/gno.land/r/sunspirit/home/gno.mod new file mode 100644 index 00000000000..2aea0280fff --- /dev/null +++ b/examples/gno.land/r/sunspirit/home/gno.mod @@ -0,0 +1 @@ +module gno.land/r/sunspirit/home diff --git a/examples/gno.land/r/sunspirit/home/home.gno b/examples/gno.land/r/sunspirit/home/home.gno new file mode 100644 index 00000000000..fbf9709e8d4 --- /dev/null +++ b/examples/gno.land/r/sunspirit/home/home.gno @@ -0,0 +1,34 @@ +package home + +import ( + "strings" + + "gno.land/p/demo/ufmt" + "gno.land/p/sunspirit/md" +) + +func Render(path string) string { + var sb strings.Builder + + sb.WriteString(md.H1("Sunspirit's Home") + md.LineBreak(1)) + + sb.WriteString(md.Paragraph(ufmt.Sprintf( + "Welcome to Sunspirit’s home! This is where I’ll bring %s to Gno.land, crafted with my experience and creativity.", + md.Italic(md.Bold("simple, useful dapps")), + )) + md.LineBreak(1)) + + sb.WriteString(md.Paragraph(ufmt.Sprintf( + "📚 I’ve created a Markdown rendering library at %s. Feel free to use it for your own projects!", + md.Link("gno.land/p/sunspirit/md", "/p/sunspirit/md"), + )) + md.LineBreak(1)) + + sb.WriteString(md.Paragraph("💬 I’d love to hear your feedback to help improve this library!") + md.LineBreak(1)) + + sb.WriteString(md.Paragraph(ufmt.Sprintf( + "🌐 You can check out a demo of this package in action at %s.", + md.Link("gno.land/r/sunspirit/md", "/r/sunspirit/md"), + )) + md.LineBreak(1)) + sb.WriteString(md.HorizontalRule()) + + return sb.String() +} diff --git a/examples/gno.land/r/sunspirit/md/gno.mod b/examples/gno.land/r/sunspirit/md/gno.mod new file mode 100644 index 00000000000..ff3a7c54d96 --- /dev/null +++ b/examples/gno.land/r/sunspirit/md/gno.mod @@ -0,0 +1 @@ +module gno.land/r/sunspirit/md diff --git a/examples/gno.land/r/sunspirit/md/md.gno b/examples/gno.land/r/sunspirit/md/md.gno new file mode 100644 index 00000000000..8c21ea0215c --- /dev/null +++ b/examples/gno.land/r/sunspirit/md/md.gno @@ -0,0 +1,158 @@ +package md + +import ( + "gno.land/p/sunspirit/md" + "gno.land/p/sunspirit/table" +) + +func Render(path string) string { + title := "A simple, flexible, and easy-to-use library for creating markdown documents in gno.land" + + mdBuilder := md.NewBuilder(). + Add(md.H1(md.Italic(md.Bold(title)))). + + // Bold Text section + Add( + md.H3(md.Bold("1. Bold Text")), + md.Paragraph("To make text bold, use the `md.Bold()` function:"), + md.Bold("This is bold text"), + ). + + // Italic Text section + Add( + md.H3(md.Bold("2. Italic Text")), + md.Paragraph("To make text italic, use the `md.Italic()` function:"), + md.Italic("This is italic text"), + ). + + // Strikethrough Text section + Add( + md.H3(md.Bold("3. Strikethrough Text")), + md.Paragraph("To add strikethrough, use the `md.Strikethrough()` function:"), + md.Strikethrough("This text is strikethrough"), + ). + + // Headers section + Add( + md.H3(md.Bold("4. Headers (H1 to H6)")), + md.Paragraph("You can create headers (H1 to H6) using the `md.H1()` to `md.H6()` functions:"), + md.H1("This is a level 1 header"), + md.H2("This is a level 2 header"), + md.H3("This is a level 3 header"), + md.H4("This is a level 4 header"), + md.H5("This is a level 5 header"), + md.H6("This is a level 6 header"), + ). + + // Bullet List section + Add( + md.H3(md.Bold("5. Bullet List")), + md.Paragraph("To create bullet lists, use the `md.BulletList()` function:"), + md.BulletList([]string{"Item 1", "Item 2", "Item 3"}), + ). + + // Ordered List section + Add( + md.H3(md.Bold("6. Ordered List")), + md.Paragraph("To create ordered lists, use the `md.OrderedList()` function:"), + md.OrderedList([]string{"First", "Second", "Third"}), + ). + + // Todo List section + Add( + md.H3(md.Bold("7. Todo List")), + md.Paragraph("You can create a todo list using the `md.TodoList()` function, which supports checkboxes:"), + md.TodoList([]string{"Task 1", "Task 2"}, []bool{true, false}), + ). + + // Blockquote section + Add( + md.H3(md.Bold("8. Blockquote")), + md.Paragraph("To create blockquotes, use the `md.Blockquote()` function:"), + md.Blockquote("This is a blockquote.\nIt can span multiple lines."), + ). + + // Inline Code section + Add( + md.H3(md.Bold("9. Inline Code")), + md.Paragraph("To insert inline code, use the `md.InlineCode()` function:"), + md.InlineCode("fmt.Println() // inline code"), + ). + + // Code Block section + Add( + md.H3(md.Bold("10. Code Block")), + md.Paragraph("For multi-line code blocks, use the `md.CodeBlock()` function:"), + md.CodeBlock("package main\n\nfunc main() {\n\t// Your code here\n}"), + ). + + // Horizontal Rule section + Add( + md.H3(md.Bold("11. Horizontal Rule")), + md.Paragraph("To add a horizontal rule (separator), use the `md.HorizontalRule()` function:"), + md.LineBreak(1), + md.HorizontalRule(), + ). + + // Language-specific Code Block section + Add( + md.H3(md.Bold("12. Language-specific Code Block")), + md.Paragraph("To create language-specific code blocks, use the `md.LanguageCodeBlock()` function:"), + md.LanguageCodeBlock("go", "package main\n\nfunc main() {}"), + ). + + // Hyperlink section + Add( + md.H3(md.Bold("13. Hyperlink")), + md.Paragraph("To create a hyperlink, use the `md.Link()` function:"), + md.Link("Gnoland official docs", "https://docs.gno.land"), + ). + + // Image section + Add( + md.H3(md.Bold("14. Image")), + md.Paragraph("To insert an image, use the `md.Image()` function:"), + md.LineBreak(1), + md.Image("Gnoland Logo", "https://gnolang.github.io/blog/2024-05-21_the-gnome/src/banner.png"), + ). + + // Footnote section + Add( + md.H3(md.Bold("15. Footnote")), + md.Paragraph("To create footnotes, use the `md.Footnote()` function:"), + md.LineBreak(1), + md.Footnote("1", "This is a footnote."), + ). + + // Table section + Add( + md.H3(md.Bold("16. Table")), + md.Paragraph("To create a table, use the `md.Table()` function. Here's an example of a table:"), + ) + + // Create a table using the table package + tb, _ := table.New([]string{"Feature", "Description"}, [][]string{ + {"Bold", "Make text bold using " + md.Bold("double asterisks")}, + {"Italic", "Make text italic using " + md.Italic("single asterisks")}, + {"Strikethrough", "Cross out text using " + md.Strikethrough("double tildes")}, + }) + mdBuilder.Add(md.Table(tb)) + + // Escaping Markdown section + mdBuilder.Add( + md.H3(md.Bold("17. Escaping Markdown")), + md.Paragraph("Sometimes, you need to escape special Markdown characters (like *, _, and `). Use the `md.EscapeMarkdown()` function for this:"), + ) + + // Example of escaping markdown + text := "- Escape special chars like *, _, and ` in markdown" + mdBuilder.Add( + md.H4("Text Without Escape:"), + text, + md.LineBreak(1), + md.H4("Text With Escape:"), + md.EscapeMarkdown(text), + ) + + return mdBuilder.Render(md.LineBreak(1)) +} diff --git a/examples/gno.land/r/sunspirit/md/md_test.gno b/examples/gno.land/r/sunspirit/md/md_test.gno new file mode 100644 index 00000000000..2e1ce9b9931 --- /dev/null +++ b/examples/gno.land/r/sunspirit/md/md_test.gno @@ -0,0 +1,13 @@ +package md + +import ( + "strings" + "testing" +) + +func TestRender(t *testing.T) { + output := Render("") + if !strings.Contains(output, "A simple, flexible, and easy-to-use library for creating markdown documents in gno.land") { + t.Errorf("invalid output") + } +} diff --git a/examples/gno.land/r/sys/params/params_test.gno b/examples/gno.land/r/sys/params/params_test.gno index eaa1ad039d3..a15da1e7499 100644 --- a/examples/gno.land/r/sys/params/params_test.gno +++ b/examples/gno.land/r/sys/params/params_test.gno @@ -1,6 +1,10 @@ package params -import "testing" +import ( + "testing" + + _ "gno.land/r/gov/dao/init" // so that loader.init is executed +) // Testing this package is limited because it only contains an `std.Set` method // without a corresponding `std.Get` method. For comprehensive testing, refer to diff --git a/gno.land/cmd/gnoland/imports_test.go b/gno.land/cmd/gnoland/imports_test.go new file mode 100644 index 00000000000..c5ae81599b4 --- /dev/null +++ b/gno.land/cmd/gnoland/imports_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNoTestingStdlibImport(t *testing.T) { + // See: https://github.com/gnolang/gno/issues/3585 + // The gno.land binary should not import testing stdlibs, which contain unsafe + // code in the respective native bindings. + + res, err := exec.Command("go", "list", "-f", `{{ join .Deps "\n" }}`, ".").CombinedOutput() + require.NoError(t, err) + assert.Contains(t, string(res), "github.com/gnolang/gno/gnovm/stdlibs\n", "should contain normal stdlibs") + assert.NotContains(t, string(res), "github.com/gnolang/gno/gnovm/tests/stdlibs\n", "should not contain test stdlibs") +} diff --git a/gno.land/pkg/gnoweb/components/layout_header.go b/gno.land/pkg/gnoweb/components/layout_header.go index b85efde5f85..8ab5e95b391 100644 --- a/gno.land/pkg/gnoweb/components/layout_header.go +++ b/gno.land/pkg/gnoweb/components/layout_header.go @@ -2,6 +2,8 @@ package components import ( "net/url" + + "github.com/gnolang/gno/gno.land/pkg/gnoweb/weburl" ) type HeaderLink struct { @@ -13,36 +15,44 @@ type HeaderLink struct { type HeaderData struct { RealmPath string + RealmURL weburl.GnoURL Breadcrumb BreadcrumbData - WebQuery url.Values Links []HeaderLink + ChainId string + Remote string } -func StaticHeaderLinks(realmPath string, webQuery url.Values) []HeaderLink { +func StaticHeaderLinks(u weburl.GnoURL) []HeaderLink { + contentURL, sourceURL, helpURL := u, u, u + contentURL.WebQuery = url.Values{} + sourceURL.WebQuery = url.Values{"source": {""}} + helpURL.WebQuery = url.Values{"help": {""}} + return []HeaderLink{ { Label: "Content", - URL: realmPath, + URL: contentURL.EncodeWebURL(), Icon: "ico-info", - IsActive: isActive(webQuery, "Content"), + IsActive: isActive(u.WebQuery, "Content"), }, { Label: "Source", - URL: realmPath + "$source", + URL: sourceURL.EncodeWebURL(), Icon: "ico-code", - IsActive: isActive(webQuery, "Source"), + IsActive: isActive(u.WebQuery, "Source"), }, { Label: "Docs", - URL: realmPath + "$help", + URL: helpURL.EncodeWebURL(), Icon: "ico-docs", - IsActive: isActive(webQuery, "Docs"), + IsActive: isActive(u.WebQuery, "Docs"), }, } } func EnrichHeaderData(data HeaderData) HeaderData { - data.Links = StaticHeaderLinks(data.RealmPath, data.WebQuery) + data.RealmPath = data.RealmURL.EncodeURL() + data.Links = StaticHeaderLinks(data.RealmURL) return data } diff --git a/gno.land/pkg/gnoweb/components/layouts/header.html b/gno.land/pkg/gnoweb/components/layouts/header.html index 8a1433ccd1c..851833b1dc0 100644 --- a/gno.land/pkg/gnoweb/components/layouts/header.html +++ b/gno.land/pkg/gnoweb/components/layouts/header.html @@ -6,19 +6,70 @@ Gno username profile pic -
{{ range .Links }} {{ template "ui/header_link" . }} {{ end }}
-{{ end }} +{{ end }} diff --git a/gno.land/pkg/gnoweb/components/ui/icons.html b/gno.land/pkg/gnoweb/components/ui/icons.html index feef8226be7..f1145d74359 100644 --- a/gno.land/pkg/gnoweb/components/ui/icons.html +++ b/gno.land/pkg/gnoweb/components/ui/icons.html @@ -120,5 +120,50 @@ fill="transparent" /> + + + + + + + + + + + + + + + {{ end }} diff --git a/gno.land/pkg/gnoweb/frontend/css/tx.config.js b/gno.land/pkg/gnoweb/frontend/css/tx.config.js index 451688d7da6..06aa685676a 100644 --- a/gno.land/pkg/gnoweb/frontend/css/tx.config.js +++ b/gno.land/pkg/gnoweb/frontend/css/tx.config.js @@ -26,6 +26,7 @@ export default { borderRadius: { sm: `${pxToRem(4)}rem`, DEFAULT: `${pxToRem(6)}rem`, + full: "9999px", }, colors: { light: "#FFFFFF", diff --git a/gno.land/pkg/gnoweb/frontend/js/realmhelp.ts b/gno.land/pkg/gnoweb/frontend/js/realmhelp.ts index 3177e034257..950c85cdbe3 100644 --- a/gno.land/pkg/gnoweb/frontend/js/realmhelp.ts +++ b/gno.land/pkg/gnoweb/frontend/js/realmhelp.ts @@ -1,4 +1,4 @@ -import { debounce } from "./utils"; +import { debounce, escapeShellSpecialChars } from "./utils"; class Help { private DOM: { @@ -67,7 +67,7 @@ class Help { localStorage.setItem("helpAddressInput", address); this.funcList.forEach((func) => func.updateAddr(address)); - }); + }, 50); addressInput?.addEventListener("input", () => debouncedUpdate(addressInput)); cmdModeSelect?.addEventListener("change", (e) => { @@ -124,7 +124,7 @@ class HelpFunc { private bindEvents(): void { const debouncedUpdate = debounce((paramName: string, paramValue: string) => { if (paramName) this.updateArg(paramName, paramValue); - }); + }, 50); this.DOM.el.addEventListener("input", (e) => { const target = e.target as HTMLInputElement; @@ -143,10 +143,11 @@ class HelpFunc { } public updateArg(paramName: string, paramValue: string): void { + const escapedValue = escapeShellSpecialChars(paramValue); this.DOM.args .filter((arg) => arg.dataset.arg === paramName) .forEach((arg) => { - arg.textContent = paramValue || ""; + arg.textContent = escapedValue || ""; }); } diff --git a/gno.land/pkg/gnoweb/frontend/js/utils.ts b/gno.land/pkg/gnoweb/frontend/js/utils.ts index 83de509efa5..d975b4516f3 100644 --- a/gno.land/pkg/gnoweb/frontend/js/utils.ts +++ b/gno.land/pkg/gnoweb/frontend/js/utils.ts @@ -10,3 +10,7 @@ export function debounce void>(func: T, delay: num }, delay); }; } + +export function escapeShellSpecialChars(arg: string): string { + return arg.replace(/([$`"\\!|&;<>*?{}()])/g, "\\$1"); +} diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go index ac39f4ce0f9..b5ee98614f3 100644 --- a/gno.land/pkg/gnoweb/handler.go +++ b/gno.land/pkg/gnoweb/handler.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gnolang/gno/gno.land/pkg/gnoweb/components" + "github.com/gnolang/gno/gno.land/pkg/gnoweb/weburl" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // For error types ) @@ -111,7 +112,7 @@ func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) { // prepareIndexBodyView prepares the data and main view for the index. func (h *WebHandler) prepareIndexBodyView(r *http.Request, indexData *components.IndexData) (int, *components.View) { - gnourl, err := ParseGnoURL(r.URL) + gnourl, err := weburl.ParseGnoURL(r.URL) if err != nil { h.Logger.Warn("unable to parse url path", "path", r.URL.Path, "error", err) return http.StatusNotFound, components.StatusErrorComponent("invalid path") @@ -120,9 +121,10 @@ func (h *WebHandler) prepareIndexBodyView(r *http.Request, indexData *components breadcrumb := generateBreadcrumbPaths(gnourl) indexData.HeadData.Title = h.Static.Domain + " - " + gnourl.Path indexData.HeaderData = components.HeaderData{ - RealmPath: gnourl.Encode(EncodePath | EncodeArgs | EncodeQuery | EncodeNoEscape), Breadcrumb: breadcrumb, - WebQuery: gnourl.WebQuery, + RealmURL: *gnourl, + ChainId: h.Static.ChainId, + Remote: h.Static.RemoteHelp, } switch { @@ -135,7 +137,7 @@ func (h *WebHandler) prepareIndexBodyView(r *http.Request, indexData *components } // GetPackageView handles package pages. -func (h *WebHandler) GetPackageView(gnourl *GnoURL) (int, *components.View) { +func (h *WebHandler) GetPackageView(gnourl *weburl.GnoURL) (int, *components.View) { // Handle Help page if gnourl.WebQuery.Has("help") { return h.GetHelpView(gnourl) @@ -155,7 +157,7 @@ func (h *WebHandler) GetPackageView(gnourl *GnoURL) (int, *components.View) { return h.GetRealmView(gnourl) } -func (h *WebHandler) GetRealmView(gnourl *GnoURL) (int, *components.View) { +func (h *WebHandler) GetRealmView(gnourl *weburl.GnoURL) (int, *components.View) { var content bytes.Buffer meta, err := h.Client.RenderRealm(&content, gnourl.Path, gnourl.EncodeArgs()) @@ -179,7 +181,7 @@ func (h *WebHandler) GetRealmView(gnourl *GnoURL) (int, *components.View) { }) } -func (h *WebHandler) GetHelpView(gnourl *GnoURL) (int, *components.View) { +func (h *WebHandler) GetHelpView(gnourl *weburl.GnoURL) (int, *components.View) { fsigs, err := h.Client.Functions(gnourl.Path) if err != nil { h.Logger.Error("unable to fetch path functions", "error", err) @@ -217,7 +219,7 @@ func (h *WebHandler) GetHelpView(gnourl *GnoURL) (int, *components.View) { }) } -func (h *WebHandler) GetSourceView(gnourl *GnoURL) (int, *components.View) { +func (h *WebHandler) GetSourceView(gnourl *weburl.GnoURL) (int, *components.View) { pkgPath := gnourl.Path files, err := h.Client.Sources(pkgPath) if err != nil { @@ -260,7 +262,7 @@ func (h *WebHandler) GetSourceView(gnourl *GnoURL) (int, *components.View) { }) } -func (h *WebHandler) GetDirectoryView(gnourl *GnoURL) (int, *components.View) { +func (h *WebHandler) GetDirectoryView(gnourl *weburl.GnoURL) (int, *components.View) { pkgPath := strings.TrimSuffix(gnourl.Path, "/") files, err := h.Client.Sources(pkgPath) if err != nil { @@ -280,7 +282,7 @@ func (h *WebHandler) GetDirectoryView(gnourl *GnoURL) (int, *components.View) { }) } -func GetClientErrorStatusPage(_ *GnoURL, err error) (int, *components.View) { +func GetClientErrorStatusPage(_ *weburl.GnoURL, err error) (int, *components.View) { if err == nil { return http.StatusOK, nil } @@ -297,7 +299,7 @@ func GetClientErrorStatusPage(_ *GnoURL, err error) (int, *components.View) { } } -func generateBreadcrumbPaths(url *GnoURL) components.BreadcrumbData { +func generateBreadcrumbPaths(url *weburl.GnoURL) components.BreadcrumbData { split := strings.Split(url.Path, "/") var data components.BreadcrumbData diff --git a/gno.land/pkg/gnoweb/public/js/realmhelp.js b/gno.land/pkg/gnoweb/public/js/realmhelp.js index 68bcafbb75f..7008a54514e 100644 --- a/gno.land/pkg/gnoweb/public/js/realmhelp.js +++ b/gno.land/pkg/gnoweb/public/js/realmhelp.js @@ -1 +1 @@ -function d(a,e=250){let t;return function(...s){t!==void 0&&clearTimeout(t),t=setTimeout(()=>{a.apply(this,s)},e)}}var l=class a{DOM;funcList;static SELECTORS={container:".js-help-view",func:"[data-func]",addressInput:"[data-role='help-input-addr']",cmdModeSelect:"[data-role='help-select-mode']"};constructor(){this.DOM={el:document.querySelector(a.SELECTORS.container),funcs:[],addressInput:null,cmdModeSelect:null},this.funcList=[],this.DOM.el?this.init():console.warn("Help: Main container not found.")}init(){let{el:e}=this.DOM;e&&(this.DOM.funcs=Array.from(e.querySelectorAll(a.SELECTORS.func)),this.DOM.addressInput=e.querySelector(a.SELECTORS.addressInput),this.DOM.cmdModeSelect=e.querySelector(a.SELECTORS.cmdModeSelect),this.funcList=this.DOM.funcs.map(t=>new o(t)),this.restoreAddress(),this.bindEvents())}restoreAddress(){let{addressInput:e}=this.DOM;if(e){let t=localStorage.getItem("helpAddressInput");t&&(e.value=t,this.funcList.forEach(s=>s.updateAddr(t)))}}bindEvents(){let{addressInput:e,cmdModeSelect:t}=this.DOM,s=d(r=>{let n=r.value;localStorage.setItem("helpAddressInput",n),this.funcList.forEach(i=>i.updateAddr(n))});e?.addEventListener("input",()=>s(e)),t?.addEventListener("change",r=>{let n=r.target;this.funcList.forEach(i=>i.updateMode(n.value))})}},o=class a{DOM;funcName;static SELECTORS={address:"[data-role='help-code-address']",args:"[data-role='help-code-args']",mode:"[data-code-mode]",paramInput:"[data-role='help-param-input']"};constructor(e){this.DOM={el:e,addrs:Array.from(e.querySelectorAll(a.SELECTORS.address)),args:Array.from(e.querySelectorAll(a.SELECTORS.args)),modes:Array.from(e.querySelectorAll(a.SELECTORS.mode)),paramInputs:Array.from(e.querySelectorAll(a.SELECTORS.paramInput))},this.funcName=e.dataset.func||null,this.initializeArgs(),this.bindEvents()}static sanitizeArgsInput(e){let t=e.dataset.param||"",s=e.value.trim();return t||console.warn("sanitizeArgsInput: param is missing in arg input dataset."),{paramName:t,paramValue:s}}bindEvents(){let e=d((t,s)=>{t&&this.updateArg(t,s)});this.DOM.el.addEventListener("input",t=>{let s=t.target;if(s.dataset.role==="help-param-input"){let{paramName:r,paramValue:n}=a.sanitizeArgsInput(s);e(r,n)}})}initializeArgs(){this.DOM.paramInputs.forEach(e=>{let{paramName:t,paramValue:s}=a.sanitizeArgsInput(e);t&&this.updateArg(t,s)})}updateArg(e,t){this.DOM.args.filter(s=>s.dataset.arg===e).forEach(s=>{s.textContent=t||""})}updateAddr(e){this.DOM.addrs.forEach(t=>{t.textContent=e.trim()||"ADDRESS"})}updateMode(e){this.DOM.modes.forEach(t=>{let s=t.dataset.codeMode===e;t.classList.toggle("inline",s),t.classList.toggle("hidden",!s),t.dataset.copyContent=s?`help-cmd-${this.funcName}`:""})}},p=()=>new l;export{p as default}; +function d(s,e=250){let t;return function(...a){t!==void 0&&clearTimeout(t),t=setTimeout(()=>{s.apply(this,a)},e)}}function u(s){return s.replace(/([$`"\\!|&;<>*?{}()])/g,"\\$1")}var l=class s{DOM;funcList;static SELECTORS={container:".js-help-view",func:"[data-func]",addressInput:"[data-role='help-input-addr']",cmdModeSelect:"[data-role='help-select-mode']"};constructor(){this.DOM={el:document.querySelector(s.SELECTORS.container),funcs:[],addressInput:null,cmdModeSelect:null},this.funcList=[],this.DOM.el?this.init():console.warn("Help: Main container not found.")}init(){let{el:e}=this.DOM;e&&(this.DOM.funcs=Array.from(e.querySelectorAll(s.SELECTORS.func)),this.DOM.addressInput=e.querySelector(s.SELECTORS.addressInput),this.DOM.cmdModeSelect=e.querySelector(s.SELECTORS.cmdModeSelect),this.funcList=this.DOM.funcs.map(t=>new o(t)),this.restoreAddress(),this.bindEvents())}restoreAddress(){let{addressInput:e}=this.DOM;if(e){let t=localStorage.getItem("helpAddressInput");t&&(e.value=t,this.funcList.forEach(a=>a.updateAddr(t)))}}bindEvents(){let{addressInput:e,cmdModeSelect:t}=this.DOM,a=d(n=>{let r=n.value;localStorage.setItem("helpAddressInput",r),this.funcList.forEach(i=>i.updateAddr(r))},50);e?.addEventListener("input",()=>a(e)),t?.addEventListener("change",n=>{let r=n.target;this.funcList.forEach(i=>i.updateMode(r.value))})}},o=class s{DOM;funcName;static SELECTORS={address:"[data-role='help-code-address']",args:"[data-role='help-code-args']",mode:"[data-code-mode]",paramInput:"[data-role='help-param-input']"};constructor(e){this.DOM={el:e,addrs:Array.from(e.querySelectorAll(s.SELECTORS.address)),args:Array.from(e.querySelectorAll(s.SELECTORS.args)),modes:Array.from(e.querySelectorAll(s.SELECTORS.mode)),paramInputs:Array.from(e.querySelectorAll(s.SELECTORS.paramInput))},this.funcName=e.dataset.func||null,this.initializeArgs(),this.bindEvents()}static sanitizeArgsInput(e){let t=e.dataset.param||"",a=e.value.trim();return t||console.warn("sanitizeArgsInput: param is missing in arg input dataset."),{paramName:t,paramValue:a}}bindEvents(){let e=d((t,a)=>{t&&this.updateArg(t,a)},50);this.DOM.el.addEventListener("input",t=>{let a=t.target;if(a.dataset.role==="help-param-input"){let{paramName:n,paramValue:r}=s.sanitizeArgsInput(a);e(n,r)}})}initializeArgs(){this.DOM.paramInputs.forEach(e=>{let{paramName:t,paramValue:a}=s.sanitizeArgsInput(e);t&&this.updateArg(t,a)})}updateArg(e,t){let a=u(t);this.DOM.args.filter(n=>n.dataset.arg===e).forEach(n=>{n.textContent=a||""})}updateAddr(e){this.DOM.addrs.forEach(t=>{t.textContent=e.trim()||"ADDRESS"})}updateMode(e){this.DOM.modes.forEach(t=>{let a=t.dataset.codeMode===e;t.classList.toggle("inline",a),t.classList.toggle("hidden",!a),t.dataset.copyContent=a?`help-cmd-${this.funcName}`:""})}},m=()=>new l;export{m as default}; diff --git a/gno.land/pkg/gnoweb/public/js/utils.js b/gno.land/pkg/gnoweb/public/js/utils.js index e27fb93bc1c..ce96def444a 100644 --- a/gno.land/pkg/gnoweb/public/js/utils.js +++ b/gno.land/pkg/gnoweb/public/js/utils.js @@ -1 +1 @@ -function r(t,n=250){let e;return function(...i){e!==void 0&&clearTimeout(e),e=setTimeout(()=>{t.apply(this,i)},n)}}export{r as debounce}; +function i(e,n=250){let t;return function(...r){t!==void 0&&clearTimeout(t),t=setTimeout(()=>{e.apply(this,r)},n)}}function a(e){return e.replace(/([$`"\\!|&;<>*?{}()])/g,"\\$1")}export{i as debounce,a as escapeShellSpecialChars}; diff --git a/gno.land/pkg/gnoweb/public/styles.css b/gno.land/pkg/gnoweb/public/styles.css index ec575bb3735..96e768a313e 100644 --- a/gno.land/pkg/gnoweb/public/styles.css +++ b/gno.land/pkg/gnoweb/public/styles.css @@ -1,3 +1,3 @@ @font-face{font-family:Roboto;font-style:normal;font-weight:900;font-display:swap;src:url(fonts/roboto/roboto-mono-normal.woff2) format("woff2"),url(fonts/roboto/roboto-mono-normal.woff) format("woff")}@font-face{font-family:Inter var;font-weight:100 900;font-display:block;font-style:oblique 0deg 10deg;src:url(fonts/intervar/Intervar.woff2) format("woff2")}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #bdbdbd}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#7c7c7c}input::placeholder,textarea::placeholder{opacity:1;color:#7c7c7c}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));font-family:Inter var,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji,sans-serif;font-size:1rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));font-feature-settings:"kern" on,"liga" on,"calt" on,"zero" on;-webkit-font-feature-settings:"kern" on,"liga" on,"calt" on,"zero" on;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;-moz-osx-font-smoothing:grayscale;font-smoothing:antialiased;font-variant-ligatures:contextual common-ligatures;font-kerning:normal;text-rendering:optimizeLegibility}svg{max-height:100%;max-width:100%}form{margin-top:0;margin-bottom:0}.realm-view{overflow-wrap:break-word;padding-top:1.5rem;font-size:1rem}@media (min-width:51.25rem){.realm-view{padding-top:2.5rem}}.realm-view>:first-child{margin-top:0!important}.realm-view a{font-weight:500;--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.realm-view a:hover{text-decoration-line:underline}.realm-view h1,.realm-view h2,.realm-view h3,.realm-view h4{margin-top:3rem;line-height:1.25;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.realm-view h2,.realm-view h2 *{font-weight:700}.realm-view h3,.realm-view h3 *,.realm-view h4,.realm-view h4 *{font-weight:600}.realm-view h1+h2,.realm-view h2+h3,.realm-view h3+h4{margin-top:1rem}.realm-view h1{font-size:2.375rem;font-weight:700}.realm-view h2{font-size:1.5rem}.realm-view h3{margin-top:2.5rem;font-size:1.25rem}.realm-view h3,.realm-view h4{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-view h4{margin-top:1.5rem;margin-bottom:1.5rem;font-size:1.125rem;font-weight:500}.realm-view p{margin-top:1.25rem;margin-bottom:1.25rem}.realm-view strong{font-weight:700;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.realm-view strong *{font-weight:700}.realm-view em{font-style:oblique 14deg}.realm-view blockquote{margin-top:1rem;margin-bottom:1rem;border-left-width:4px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));font-style:oblique 14deg}.realm-view ol,.realm-view ul{margin-top:1.5rem;margin-bottom:1.5rem;padding-left:1rem}.realm-view ol li,.realm-view ul li{margin-bottom:.5rem}.realm-view img{margin-top:2rem;margin-bottom:2rem;max-width:100%}.realm-view figure{margin-top:1.5rem;margin-bottom:1.5rem;text-align:center}.realm-view figcaption{font-size:.875rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-view :not(pre)>code{border-radius:.25rem;background-color:rgb(226 226 226/var(--tw-bg-opacity));padding:.125rem .25rem;font-size:.96em}.realm-view :not(pre)>code,.realm-view pre{--tw-bg-opacity:1;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.realm-view pre{overflow-x:auto;border-radius:.375rem;background-color:rgb(240 240 240/var(--tw-bg-opacity));padding:1rem}.realm-view hr{margin-top:2.5rem;margin-bottom:2.5rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity))}.realm-view table{margin-top:2rem;margin-bottom:2rem;display:block;width:100%;max-width:100%;border-collapse:collapse;overflow-x:auto}.realm-view td,.realm-view th{white-space:normal;overflow-wrap:break-word;border-width:1px;padding:.5rem 1rem}.realm-view th{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));font-weight:700}.realm-view caption{margin-top:.5rem;text-align:left;font-size:.875rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-view q{margin-top:1.5rem;margin-bottom:1.5rem;border-left-width:4px;--tw-border-opacity:1;border-left-color:rgb(204 204 204/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;color:rgb(85 85 85/var(--tw-text-opacity));font-style:oblique 14deg;quotes:"“" "”" "‘" "’"}.realm-view q:after,.realm-view q:before{margin-right:.25rem;font-size:1.5rem;--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity));content:open-quote;vertical-align:-.4rem}.realm-view q:after{content:close-quote}.realm-view q:before{content:open-quote}.realm-view q:after{content:close-quote}.realm-view ol ol,.realm-view ol ul,.realm-view ul ol,.realm-view ul ul{margin-top:.75rem;margin-bottom:.5rem;padding-left:1rem}.realm-view ul{list-style-type:disc}.realm-view ol{list-style-type:decimal}.realm-view abbr[title]{cursor:help;border-bottom-width:1px;border-style:dotted}.realm-view details{margin-top:1.25rem;margin-bottom:1.25rem}.realm-view summary{cursor:pointer;font-weight:700}.realm-view a code{color:inherit}.realm-view video{margin-top:2rem;margin-bottom:2rem;max-width:100%}.realm-view math{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.realm-view small{font-size:.875rem}.realm-view del{text-decoration-line:line-through}.realm-view sub{vertical-align:sub;font-size:.75rem}.realm-view sup{vertical-align:super;font-size:.75rem}.realm-view button,.realm-view input{border-width:1px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding:.5rem 1rem}main :is(h1,h2,h3,h4){scroll-margin-top:6rem}::-moz-selection{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}::selection{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.sidemenu .peer:checked+label>svg{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.toc-expend-btn:has(#toc-expend:checked)+nav{display:block}.toc-expend-btn:has(#toc-expend:checked) .toc-expend-btn_ico{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.main-header:has(#sidemenu-docs:checked)+main #sidebar #sidebar-docs,.main-header:has(#sidemenu-meta:checked)+main #sidebar #sidebar-meta,.main-header:has(#sidemenu-source:checked)+main #sidebar #sidebar-source,.main-header:has(#sidemenu-summary:checked)+main #sidebar #sidebar-summary{display:block}@media (min-width:40rem){:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked)) .main-navigation,:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main .realm-view{grid-column:span 6/span 6}:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked)) .sidemenu,:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main #sidebar{grid-column:span 4/span 4}}:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main #sidebar:before{position:absolute;top:0;left:-1.75rem;z-index:-1;display:block;height:100%;width:50vw;--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));--tw-content:"";content:var(--tw-content)}main :is(.source-code)>pre{overflow:scroll;border-radius:.375rem;--tw-bg-opacity:1!important;background-color:rgb(255 255 255/var(--tw-bg-opacity))!important;padding:1rem .25rem;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;;font-size:.875rem}@media (min-width:40rem){main :is(.source-code)>pre{padding:2rem .75rem;font-size:1rem}}main .realm-view>pre a:hover{text-decoration-line:none}main :is(.realm-view,.source-code)>pre .chroma-ln:target{background-color:transparent!important}main :is(.realm-view,.source-code)>pre .chroma-line:has(.chroma-ln:target),main :is(.realm-view,.source-code)>pre .chroma-line:has(.chroma-ln:target) .chroma-cl,main :is(.realm-view,.source-code)>pre .chroma-line:has(.chroma-lnlinks:hover),main :is(.realm-view,.source-code)>pre .chroma-line:has(.chroma-lnlinks:hover) .chroma-cl{border-radius:.375rem;--tw-bg-opacity:1!important;background-color:rgb(226 226 226/var(--tw-bg-opacity))!important}main :is(.realm-view,.source-code)>pre .chroma-ln{scroll-margin-top:6rem}.dev-mode .toc-expend-btn{cursor:pointer;border-width:1px;--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.dev-mode .toc-expend-btn:hover{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}@media (min-width:51.25rem){.dev-mode .toc-expend-btn{border-style:none;background-color:transparent}}.dev-mode #sidebar-summary{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}@media (min-width:51.25rem){.dev-mode #sidebar-summary{background-color:transparent}}.dev-mode .toc-nav{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.bottom-1{bottom:.25rem}.left-0{left:0}.right-2{right:.5rem}.right-3{right:.75rem}.top-0{top:0}.top-1\/2{top:50%}.top-14{top:3.5rem}.top-2{top:.5rem}.z-1{z-index:1}.z-max{z-index:9999}.col-span-1{grid-column:span 1/span 1}.col-span-10{grid-column:span 10/span 10}.col-span-3{grid-column:span 3/span 3}.col-span-7{grid-column:span 7/span 7}.row-span-1{grid-row:span 1/span 1}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.mr-10{margin-right:2.5rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-full{height:100%}.max-h-screen{max-height:100vh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-full{width:100%}.min-w-2{min-width:.5rem}.min-w-48{min-width:12rem}.max-w-screen-max{max-width:98.75rem}.shrink-0{flex-shrink:0}.grow-\[2\]{flex-grow:2}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-flow-dense{grid-auto-flow:dense}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-8{gap:2rem}.gap-x-20{-moz-column-gap:5rem;column-gap:5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-y-2{row-gap:.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.375rem}.rounded-sm{border-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-gray-100{--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(153 153 153/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}.bg-light{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-transparent{background-color:transparent}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-10{padding-left:2.5rem;padding-right:2.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-px{padding-top:1px;padding-bottom:1px}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pb-8{padding-bottom:2rem}.pl-4{padding-left:1rem}.pr-10{padding-right:2.5rem}.pt-0\.5{padding-top:.125rem}.pt-2{padding-top:.5rem}.font-mono{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.text-100{font-size:.875rem}.text-200{font-size:1rem}.text-50{font-size:.75rem}.text-600{font-size:1.5rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.capitalize{text-transform:capitalize}.leading-tight{line-height:1.25}.text-gray-300{--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(124 124 124/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(19 19 19/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.text-green-600{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.text-light{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.outline-none{outline:2px solid transparent;outline-offset:2px}.text-stroke{-webkit-text-stroke:currentColor;-webkit-text-stroke-width:.6px}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}.\*\:pl-0>*{padding-left:0}.before\:px-\[0\.18rem\]:before{content:var(--tw-content);padding-left:.18rem;padding-right:.18rem}.before\:text-gray-300:before{content:var(--tw-content);--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity))}.before\:content-\[\'\/\'\]:before{--tw-content:"/";content:var(--tw-content)}.before\:content-\[\'\:\'\]:before{--tw-content:":";content:var(--tw-content)}.before\:content-\[\'open\'\]:before{--tw-content:"open";content:var(--tw-content)}.after\:pointer-events-none:after{content:var(--tw-content);pointer-events:none}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:bottom-0:after{content:var(--tw-content);bottom:0}.after\:left-0:after{content:var(--tw-content);left:0}.after\:top-0:after{content:var(--tw-content);top:0}.after\:block:after{content:var(--tw-content);display:block}.after\:h-1:after{content:var(--tw-content);height:.25rem}.after\:h-full:after{content:var(--tw-content);height:100%}.after\:w-full:after{content:var(--tw-content);width:100%}.after\:rounded-t-sm:after{content:var(--tw-content);border-top-left-radius:.25rem;border-top-right-radius:.25rem}.after\:bg-gray-100:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.after\:bg-green-600:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.first\:mt-8:first-child{margin-top:2rem}.first\:border-t:first-child{border-top-width:1px}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}.hover\:bg-green-600:hover{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.hover\:text-green-600:hover{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.hover\:text-light:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-gray-300:focus{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.focus\:border-l-gray-300:focus{--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity))}.group:hover .group-hover\:border-gray-300{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.group:hover .group-hover\:border-l-gray-300{--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity))}.group.is-active .group-\[\.is-active\]\:text-green-600{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.peer:checked~.peer-checked\:before\:content-\[\'close\'\]:before{--tw-content:"close";content:var(--tw-content)}.peer:focus-within~.peer-focus-within\:hidden{display:none}.has-\[ul\:empty\]\:hidden:has(ul:empty){display:none}.has-\[\:focus-within\]\:border-gray-300:has(:focus-within){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.has-\[\:focus\]\:border-gray-300:has(:focus){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}@media (min-width:30rem){.sm\:gap-6{gap:1.5rem}}@media (min-width:40rem){.md\:col-span-3{grid-column:span 3/span 3}.md\:mb-0{margin-bottom:0}.md\:h-4{height:1rem}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.md\:px-10{padding-left:2.5rem;padding-right:2.5rem}.md\:pb-0{padding-bottom:0}}@media (min-width:51.25rem){.lg\:order-2{order:2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-7{grid-column:span 7/span 7}.lg\:row-span-2{grid-row:span 2/span 2}.lg\:row-start-1{grid-row-start:1}.lg\:mb-4{margin-bottom:1rem}.lg\:mt-0{margin-top:0}.lg\:mt-10{margin-top:2.5rem}.lg\:block{display:block}.lg\:hidden{display:none}.lg\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-start{justify-content:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:gap-x-20{-moz-column-gap:5rem;column-gap:5rem}.lg\:border-none{border-style:none}.lg\:bg-transparent{background-color:transparent}.lg\:p-0{padding:0}.lg\:px-0{padding-left:0;padding-right:0}.lg\:px-2{padding-left:.5rem;padding-right:.5rem}.lg\:py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.lg\:pb-28{padding-bottom:7rem}.lg\:pt-2{padding-top:.5rem}.lg\:text-200{font-size:1rem}.lg\:font-semibold{font-weight:600}.lg\:first\:mt-0:first-child{margin-top:0}.lg\:hover\:bg-transparent:hover{background-color:transparent}}@media (min-width:63.75rem){.xl\:inline{display:inline}.xl\:hidden{display:none}.xl\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.xl\:flex-row{flex-direction:row}.xl\:items-center{align-items:center}.xl\:gap-20{gap:5rem}.xl\:gap-6{gap:1.5rem}.xl\:pt-0{padding-top:0}}@media (min-width:85.375rem){.xxl\:inline-block{display:inline-block}.xxl\:h-4{height:1rem}.xxl\:w-4{width:1rem}.xxl\:gap-20{gap:5rem}.xxl\:gap-x-32{-moz-column-gap:8rem;column-gap:8rem}.xxl\:pr-1{padding-right:.25rem}} \ No newline at end of file +/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #bdbdbd}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#7c7c7c}input::placeholder,textarea::placeholder{opacity:1;color:#7c7c7c}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));font-family:Inter var,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji,sans-serif;font-size:1rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));font-feature-settings:"kern" on,"liga" on,"calt" on,"zero" on;-webkit-font-feature-settings:"kern" on,"liga" on,"calt" on,"zero" on;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;-moz-osx-font-smoothing:grayscale;font-smoothing:antialiased;font-variant-ligatures:contextual common-ligatures;font-kerning:normal;text-rendering:optimizeLegibility}svg{max-height:100%;max-width:100%}form{margin-top:0;margin-bottom:0}.realm-view{overflow-wrap:break-word;padding-top:1.5rem;font-size:1rem}@media (min-width:51.25rem){.realm-view{padding-top:2.5rem}}.realm-view>:first-child{margin-top:0!important}.realm-view a{font-weight:500;--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.realm-view a:hover{text-decoration-line:underline}.realm-view h1,.realm-view h2,.realm-view h3,.realm-view h4{margin-top:3rem;line-height:1.25;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.realm-view h2,.realm-view h2 *{font-weight:700}.realm-view h3,.realm-view h3 *,.realm-view h4,.realm-view h4 *{font-weight:600}.realm-view h1+h2,.realm-view h2+h3,.realm-view h3+h4{margin-top:1rem}.realm-view h1{font-size:2.375rem;font-weight:700}.realm-view h2{font-size:1.5rem}.realm-view h3{margin-top:2.5rem;font-size:1.25rem}.realm-view h3,.realm-view h4{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-view h4{margin-top:1.5rem;margin-bottom:1.5rem;font-size:1.125rem;font-weight:500}.realm-view p{margin-top:1.25rem;margin-bottom:1.25rem}.realm-view strong{font-weight:700;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.realm-view strong *{font-weight:700}.realm-view em{font-style:oblique 14deg}.realm-view blockquote{margin-top:1rem;margin-bottom:1rem;border-left-width:4px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));font-style:oblique 14deg}.realm-view ol,.realm-view ul{margin-top:1.5rem;margin-bottom:1.5rem;padding-left:1rem}.realm-view ol li,.realm-view ul li{margin-bottom:.5rem}.realm-view img{margin-top:2rem;margin-bottom:2rem;max-width:100%}.realm-view figure{margin-top:1.5rem;margin-bottom:1.5rem;text-align:center}.realm-view figcaption{font-size:.875rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-view :not(pre)>code{border-radius:.25rem;background-color:rgb(226 226 226/var(--tw-bg-opacity));padding:.125rem .25rem;font-size:.96em}.realm-view :not(pre)>code,.realm-view pre{--tw-bg-opacity:1;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.realm-view pre{overflow-x:auto;border-radius:.375rem;background-color:rgb(240 240 240/var(--tw-bg-opacity));padding:1rem}.realm-view hr{margin-top:2.5rem;margin-bottom:2.5rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity))}.realm-view table{margin-top:2rem;margin-bottom:2rem;display:block;width:100%;max-width:100%;border-collapse:collapse;overflow-x:auto}.realm-view td,.realm-view th{white-space:normal;overflow-wrap:break-word;border-width:1px;padding:.5rem 1rem}.realm-view th{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));font-weight:700}.realm-view caption{margin-top:.5rem;text-align:left;font-size:.875rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-view q{margin-top:1.5rem;margin-bottom:1.5rem;border-left-width:4px;--tw-border-opacity:1;border-left-color:rgb(204 204 204/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;color:rgb(85 85 85/var(--tw-text-opacity));font-style:oblique 14deg;quotes:"“" "”" "‘" "’"}.realm-view q:after,.realm-view q:before{margin-right:.25rem;font-size:1.5rem;--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity));content:open-quote;vertical-align:-.4rem}.realm-view q:after{content:close-quote}.realm-view q:before{content:open-quote}.realm-view q:after{content:close-quote}.realm-view ol ol,.realm-view ol ul,.realm-view ul ol,.realm-view ul ul{margin-top:.75rem;margin-bottom:.5rem;padding-left:1rem}.realm-view ul{list-style-type:disc}.realm-view ol{list-style-type:decimal}.realm-view abbr[title]{cursor:help;border-bottom-width:1px;border-style:dotted}.realm-view details{margin-top:1.25rem;margin-bottom:1.25rem}.realm-view summary{cursor:pointer;font-weight:700}.realm-view a code{color:inherit}.realm-view video{margin-top:2rem;margin-bottom:2rem;max-width:100%}.realm-view math{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.realm-view small{font-size:.875rem}.realm-view del{text-decoration-line:line-through}.realm-view sub{vertical-align:sub;font-size:.75rem}.realm-view sup{vertical-align:super;font-size:.75rem}.realm-view button,.realm-view input{border-width:1px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding:.5rem 1rem}main :is(h1,h2,h3,h4){scroll-margin-top:6rem}::-moz-selection{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}::selection{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.sidemenu .peer:checked+label>svg{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.toc-expend-btn:has(#toc-expend:checked)+nav{display:block}.toc-expend-btn:has(#toc-expend:checked) .toc-expend-btn_ico{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.main-header:has(#sidemenu-docs:checked)+main #sidebar #sidebar-docs,.main-header:has(#sidemenu-meta:checked)+main #sidebar #sidebar-meta,.main-header:has(#sidemenu-source:checked)+main #sidebar #sidebar-source,.main-header:has(#sidemenu-summary:checked)+main #sidebar #sidebar-summary{display:block}@media (min-width:40rem){:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked)) .main-navigation,:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main .realm-view{grid-column:span 6/span 6}:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked)) .sidemenu,:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main #sidebar{grid-column:span 4/span 4}}:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main #sidebar:before{position:absolute;top:0;left:-1.75rem;z-index:-1;display:block;height:100%;width:50vw;--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));--tw-content:"";content:var(--tw-content)}main :is(.source-code)>pre{overflow:scroll;border-radius:.375rem;--tw-bg-opacity:1!important;background-color:rgb(255 255 255/var(--tw-bg-opacity))!important;padding:1rem .25rem;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;;font-size:.875rem}@media (min-width:40rem){main :is(.source-code)>pre{padding:2rem .75rem;font-size:1rem}}main .realm-view>pre a:hover{text-decoration-line:none}main :is(.realm-view,.source-code)>pre .chroma-ln:target{background-color:transparent!important}main :is(.realm-view,.source-code)>pre .chroma-line:has(.chroma-ln:target),main :is(.realm-view,.source-code)>pre .chroma-line:has(.chroma-ln:target) .chroma-cl,main :is(.realm-view,.source-code)>pre .chroma-line:has(.chroma-lnlinks:hover),main :is(.realm-view,.source-code)>pre .chroma-line:has(.chroma-lnlinks:hover) .chroma-cl{border-radius:.375rem;--tw-bg-opacity:1!important;background-color:rgb(226 226 226/var(--tw-bg-opacity))!important}main :is(.realm-view,.source-code)>pre .chroma-ln{scroll-margin-top:6rem}.dev-mode .toc-expend-btn{cursor:pointer;border-width:1px;--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.dev-mode .toc-expend-btn:hover{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}@media (min-width:51.25rem){.dev-mode .toc-expend-btn{border-style:none;background-color:transparent}}.dev-mode #sidebar-summary{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}@media (min-width:51.25rem){.dev-mode #sidebar-summary{background-color:transparent}}.dev-mode .toc-nav{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-1{bottom:.25rem}.left-0{left:0}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-px{right:1px}.top-0{top:0}.top-1\/2{top:50%}.top-14{top:3.5rem}.top-2{top:.5rem}.top-px{top:1px}.z-1{z-index:1}.z-max{z-index:9999}.col-span-1{grid-column:span 1/span 1}.col-span-10{grid-column:span 10/span 10}.col-span-3{grid-column:span 3/span 3}.col-span-7{grid-column:span 7/span 7}.row-span-1{grid-row:span 1/span 1}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.mr-10{margin-right:2.5rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-\[calc\(100\%-2px\)\]{height:calc(100% - 2px)}.h-full{height:100%}.max-h-screen{max-height:100vh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-3{width:.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-72{width:18rem}.w-full{width:100%}.min-w-2{min-width:.5rem}.min-w-48{min-width:12rem}.max-w-screen-max{max-width:98.75rem}.shrink-0{flex-shrink:0}.grow-\[2\]{flex-grow:2}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.cursor-pointer{cursor:pointer}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-flow-dense{grid-auto-flow:dense}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-8{gap:2rem}.gap-x-20{-moz-column-gap:5rem;column-gap:5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-y-2{row-gap:.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.375rem}.rounded-sm{border-radius:.25rem}.rounded-r{border-top-right-radius:.375rem;border-bottom-right-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-gray-100{--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(153 153 153/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}.bg-light{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-transparent{background-color:transparent}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-10{padding-left:2.5rem;padding-right:2.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-px{padding-top:1px;padding-bottom:1px}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pb-8{padding-bottom:2rem}.pl-4{padding-left:1rem}.pr-10{padding-right:2.5rem}.pt-0\.5{padding-top:.125rem}.pt-2{padding-top:.5rem}.font-mono{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.text-100{font-size:.875rem}.text-200{font-size:1rem}.text-50{font-size:.75rem}.text-600{font-size:1.5rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.capitalize{text-transform:capitalize}.leading-tight{line-height:1.25}.text-gray-300{--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(124 124 124/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(19 19 19/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.text-green-600{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.text-light{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.opacity-0{opacity:0}.outline-none{outline:2px solid transparent;outline-offset:2px}.text-stroke{-webkit-text-stroke:currentColor;-webkit-text-stroke-width:.6px}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}.\*\:pl-0>*{padding-left:0}.before\:px-\[0\.18rem\]:before{content:var(--tw-content);padding-left:.18rem;padding-right:.18rem}.before\:text-gray-300:before{content:var(--tw-content);--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity))}.before\:content-\[\'\/\'\]:before{--tw-content:"/";content:var(--tw-content)}.before\:content-\[\'\:\'\]:before{--tw-content:":";content:var(--tw-content)}.before\:content-\[\'open\'\]:before{--tw-content:"open";content:var(--tw-content)}.after\:pointer-events-none:after{content:var(--tw-content);pointer-events:none}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:bottom-0:after{content:var(--tw-content);bottom:0}.after\:left-0:after{content:var(--tw-content);left:0}.after\:top-0:after{content:var(--tw-content);top:0}.after\:block:after{content:var(--tw-content);display:block}.after\:h-1:after{content:var(--tw-content);height:.25rem}.after\:h-full:after{content:var(--tw-content);height:100%}.after\:w-full:after{content:var(--tw-content);width:100%}.after\:rounded-t-sm:after{content:var(--tw-content);border-top-left-radius:.25rem;border-top-right-radius:.25rem}.after\:bg-gray-100:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.after\:bg-green-600:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.first\:mt-8:first-child{margin-top:2rem}.first\:border-t:first-child{border-top-width:1px}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}.hover\:bg-green-600:hover{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.hover\:text-green-600:hover{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.hover\:text-light:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-gray-300:focus{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.focus\:border-l-gray-300:focus{--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity))}.group:hover .group-hover\:border-gray-300{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.group:hover .group-hover\:border-l-gray-300{--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity))}.group.is-active .group-\[\.is-active\]\:text-green-600{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.peer:checked~.peer-checked\:visible{visibility:visible}.peer:checked~.peer-checked\:opacity-100{opacity:1}.peer:checked~.peer-checked\:before\:content-\[\'close\'\]:before{--tw-content:"close";content:var(--tw-content)}.peer:focus-within~.peer-focus-within\:hidden{display:none}.has-\[ul\:empty\]\:hidden:has(ul:empty){display:none}.has-\[\:focus-within\]\:border-gray-300:has(:focus-within){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.has-\[\:focus\]\:border-gray-300:has(:focus){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}@media (min-width:30rem){.sm\:gap-6{gap:1.5rem}}@media (min-width:40rem){.md\:col-span-3{grid-column:span 3/span 3}.md\:mb-0{margin-bottom:0}.md\:flex{display:flex}.md\:h-4{height:1rem}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.md\:px-10{padding-left:2.5rem;padding-right:2.5rem}.md\:pb-0{padding-bottom:0}.md\:pr-8{padding-right:2rem}}@media (min-width:51.25rem){.lg\:order-2{order:2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-7{grid-column:span 7/span 7}.lg\:row-span-2{grid-row:span 2/span 2}.lg\:row-start-1{grid-row-start:1}.lg\:mb-4{margin-bottom:1rem}.lg\:mt-0{margin-top:0}.lg\:mt-10{margin-top:2.5rem}.lg\:block{display:block}.lg\:hidden{display:none}.lg\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-start{justify-content:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:gap-x-20{-moz-column-gap:5rem;column-gap:5rem}.lg\:border-none{border-style:none}.lg\:bg-transparent{background-color:transparent}.lg\:p-0{padding:0}.lg\:px-0{padding-left:0;padding-right:0}.lg\:px-2{padding-left:.5rem;padding-right:.5rem}.lg\:py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.lg\:pb-28{padding-bottom:7rem}.lg\:pt-2{padding-top:.5rem}.lg\:text-200{font-size:1rem}.lg\:font-semibold{font-weight:600}.lg\:first\:mt-0:first-child{margin-top:0}.lg\:hover\:bg-transparent:hover{background-color:transparent}}@media (min-width:63.75rem){.xl\:inline{display:inline}.xl\:hidden{display:none}.xl\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.xl\:flex-row{flex-direction:row}.xl\:items-center{align-items:center}.xl\:gap-20{gap:5rem}.xl\:gap-6{gap:1.5rem}.xl\:pt-0{padding-top:0}}@media (min-width:85.375rem){.xxl\:inline-block{display:inline-block}.xxl\:h-4{height:1rem}.xxl\:w-4{width:1rem}.xxl\:gap-20{gap:5rem}.xxl\:gap-x-32{-moz-column-gap:8rem;column-gap:8rem}.xxl\:pr-1{padding-right:.25rem}} \ No newline at end of file diff --git a/gno.land/pkg/gnoweb/url.go b/gno.land/pkg/gnoweb/weburl/url.go similarity index 87% rename from gno.land/pkg/gnoweb/url.go rename to gno.land/pkg/gnoweb/weburl/url.go index 9127225d490..cbe861e9e42 100644 --- a/gno.land/pkg/gnoweb/url.go +++ b/gno.land/pkg/gnoweb/weburl/url.go @@ -1,4 +1,4 @@ -package gnoweb +package weburl import ( "errors" @@ -97,20 +97,12 @@ func (gnoURL GnoURL) Encode(encodeFlags EncodeFlag) string { if encodeFlags.Has(EncodeWebQuery) && len(gnoURL.WebQuery) > 0 { urlstr.WriteRune('$') - if noEscape { - urlstr.WriteString(NoEscapeQuery(gnoURL.WebQuery)) - } else { - urlstr.WriteString(gnoURL.WebQuery.Encode()) - } + urlstr.WriteString(EncodeValues(gnoURL.WebQuery, !noEscape)) } if encodeFlags.Has(EncodeQuery) && len(gnoURL.Query) > 0 { urlstr.WriteRune('?') - if noEscape { - urlstr.WriteString(NoEscapeQuery(gnoURL.Query)) - } else { - urlstr.WriteString(gnoURL.Query.Encode()) - } + urlstr.WriteString(EncodeValues(gnoURL.Query, !noEscape)) } return urlstr.String() @@ -140,7 +132,7 @@ func (gnoURL GnoURL) EncodeURL() string { // EncodeWebURL encodes the path, package arguments, web query, and query into a string. // This function provides the full representation of the URL. func (gnoURL GnoURL) EncodeWebURL() string { - return gnoURL.Encode(EncodePath | EncodeArgs | EncodeWebQuery | EncodeQuery) + return gnoURL.Encode(EncodePath | EncodeArgs | EncodeWebQuery | EncodeQuery | EncodeNoEscape) } // IsPure checks if the URL path represents a pure path. @@ -226,11 +218,11 @@ func ParseGnoURL(u *url.URL) (*GnoURL, error) { }, nil } -// NoEscapeQuery generates a URL-encoded query string from the given url.Values, -// without escaping the keys and values. The query parameters are sorted by key. -func NoEscapeQuery(v url.Values) string { - // Encode encodes the values into “URL encoded” form - // ("bar=baz&foo=quux") sorted by key. +// EncodeValues generates a URL-encoded query string from the given url.Values. +// This function is a modified version of Go's `url.Values.Encode()`: https://pkg.go.dev/net/url#Values.Encode +// It takes an additional `escape` boolean argument that disables escaping on keys and values. +// Additionally, if an empty string value is passed, it omits the `=` sign, resulting in `?key` instead of `?key=` to enhance URL readability. +func EncodeValues(v url.Values, escape bool) string { if len(v) == 0 { return "" } @@ -240,16 +232,29 @@ func NoEscapeQuery(v url.Values) string { keys = append(keys, k) } slices.Sort(keys) + for _, k := range keys { vs := v[k] - keyEscaped := k + keyEncoded := k + if escape { + keyEncoded = url.QueryEscape(k) + } for _, v := range vs { if buf.Len() > 0 { buf.WriteByte('&') } - buf.WriteString(keyEscaped) + buf.WriteString(keyEncoded) + + if len(v) == 0 { + continue // Skip `=` for empty values + } + buf.WriteByte('=') - buf.WriteString(v) + if escape { + buf.WriteString(url.QueryEscape(v)) + } else { + buf.WriteString(v) + } } } return buf.String() diff --git a/gno.land/pkg/gnoweb/url_test.go b/gno.land/pkg/gnoweb/weburl/url_test.go similarity index 87% rename from gno.land/pkg/gnoweb/url_test.go rename to gno.land/pkg/gnoweb/weburl/url_test.go index 7a491eaa149..682832f5b0d 100644 --- a/gno.land/pkg/gnoweb/url_test.go +++ b/gno.land/pkg/gnoweb/weburl/url_test.go @@ -1,4 +1,4 @@ -package gnoweb +package weburl import ( "net/url" @@ -301,7 +301,7 @@ func TestEncode(t *testing.T) { }, }, EncodeFlags: EncodeWebQuery | EncodeNoEscape, - Expected: "$fun$c=B$ ar&help=", + Expected: "$fun$c=B$ ar&help", }, { @@ -450,6 +450,69 @@ func TestEncode(t *testing.T) { EncodeFlags: EncodePath | EncodeArgs | EncodeQuery, Expected: "/r/demo/foo:example?hello=42", }, + + { + Name: "WebQuery with empty value", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + WebQuery: url.Values{ + "source": {""}, + }, + }, + EncodeFlags: EncodePath | EncodeWebQuery | EncodeNoEscape, + Expected: "/r/demo/foo$source", + }, + + { + Name: "WebQuery with nil", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + WebQuery: url.Values{ + "debug": nil, + }, + }, + EncodeFlags: EncodePath | EncodeWebQuery, + Expected: "/r/demo/foo$", + }, + + { + Name: "WebQuery with regular value", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + WebQuery: url.Values{ + "key": {"value"}, + }, + }, + EncodeFlags: EncodePath | EncodeWebQuery, + Expected: "/r/demo/foo$key=value", + }, + + { + Name: "WebQuery mixing empty and nil and filled values", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + WebQuery: url.Values{ + "source": {""}, + "debug": nil, + "user": {"Alice"}, + }, + }, + EncodeFlags: EncodePath | EncodeWebQuery, + Expected: "/r/demo/foo$source&user=Alice", + }, + + { + Name: "WebQuery mixing nil and filled values", + GnoURL: GnoURL{ + Path: "/r/demo/foo", + WebQuery: url.Values{ + "debug": nil, + "user": {"Alice"}, + }, + }, + EncodeFlags: EncodePath | EncodeWebQuery, + Expected: "/r/demo/foo$user=Alice", + }, } for _, tc := range testCases { diff --git a/gnovm/Makefile b/gnovm/Makefile index ce745e44aae..2206fa2c8c8 100644 --- a/gnovm/Makefile +++ b/gnovm/Makefile @@ -27,11 +27,14 @@ GOTEST_FLAGS ?= -v -p 1 -timeout=30m GNOROOT_DIR ?= $(abspath $(lastword $(MAKEFILE_LIST))/../../) # We can't use '-trimpath' yet as amino use absolute path from call stack # to find some directory: see #1236 -GOBUILD_FLAGS ?= -ldflags "-X github.com/gnolang/gno/gnovm/pkg/gnoenv._GNOROOT=$(GNOROOT_DIR)" +GOBUILD_FLAGS ?= -ldflags "-X github.com/gnolang/gno/gnovm/pkg/version.Version=$(VERSION) -X github.com/gnolang/gno/gnovm/pkg/gnoenv._GNOROOT=$(GNOROOT_DIR)" # file where to place cover profile; used for coverage commands which are # more complex than adding -coverprofile, like test.cmd.coverage. GOTEST_COVER_PROFILE ?= cmd-profile.out +# user for gno version [branch].[N]+[hash] +VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null || echo "$(shell git rev-parse --abbrev-ref HEAD).$(shell git rev-list --count HEAD)+$(shell git rev-parse --short HEAD)") + ######################################## # Dev tools .PHONY: build diff --git a/gnovm/cmd/gno/main.go b/gnovm/cmd/gno/main.go index 5f8bb7b522e..b18e610d535 100644 --- a/gnovm/cmd/gno/main.go +++ b/gnovm/cmd/gno/main.go @@ -41,6 +41,7 @@ func newGnocliCmd(io commands.IO) *commands.Command { newTestCmd(io), newToolCmd(io), // version -- show cmd/gno, golang versions + newGnoVersionCmd(io), // vet ) diff --git a/gnovm/cmd/gno/run.go b/gnovm/cmd/gno/run.go index 489016aa3d4..34bf818e8f5 100644 --- a/gnovm/cmd/gno/run.go +++ b/gnovm/cmd/gno/run.go @@ -93,7 +93,7 @@ func execRun(cfg *runCfg, args []string, io commands.IO) error { // init store and machine _, testStore := test.Store( - cfg.rootDir, false, + cfg.rootDir, stdin, stdout, stderr) if cfg.verbose { testStore.SetLogStoreOps(true) diff --git a/gnovm/cmd/gno/tool_lint.go b/gnovm/cmd/gno/tool_lint.go index ce3465b484e..6983175cea0 100644 --- a/gnovm/cmd/gno/tool_lint.go +++ b/gnovm/cmd/gno/tool_lint.go @@ -97,9 +97,9 @@ func execLint(cfg *lintCfg, args []string, io commands.IO) error { hasError := false - bs, ts := test.Store( - rootDir, false, - nopReader{}, goio.Discard, goio.Discard, + bs, ts := test.StoreWithOptions( + rootDir, nopReader{}, goio.Discard, goio.Discard, + test.StoreOptions{PreprocessOnly: true}, ) for _, pkgPath := range pkgPaths { @@ -162,13 +162,10 @@ func execLint(cfg *lintCfg, args []string, io commands.IO) error { tm := test.Machine(gs, goio.Discard, memPkg.Path) defer tm.Release() - // Check package - tm.RunMemPackage(memPkg, true) - // Check test files - testFiles := lintTestFiles(memPkg) + packageFiles := sourceAndTestFileset(memPkg) - tm.RunFiles(testFiles.Files...) + tm.PreprocessFiles(memPkg.Name, memPkg.Path, packageFiles, false, false) }) if hasRuntimeErr { hasError = true @@ -221,20 +218,21 @@ func lintTypeCheck(io commands.IO, memPkg *gnovm.MemPackage, testStore gno.Store return true, nil } -func lintTestFiles(memPkg *gnovm.MemPackage) *gno.FileSet { +func sourceAndTestFileset(memPkg *gnovm.MemPackage) *gno.FileSet { testfiles := &gno.FileSet{} for _, mfile := range memPkg.Files { if !strings.HasSuffix(mfile.Name, ".gno") { continue // Skip non-GNO files } - n, _ := gno.ParseFile(mfile.Name, mfile.Body) + n := gno.MustParseFile(mfile.Name, mfile.Body) if n == nil { continue // Skip empty files } // XXX: package ending with `_test` is not supported yet - if strings.HasSuffix(mfile.Name, "_test.gno") && !strings.HasSuffix(string(n.PkgName), "_test") { + if !strings.HasSuffix(mfile.Name, "_filetest.gno") && + !strings.HasSuffix(string(n.PkgName), "_test") { // Keep only test files testfiles.AddFiles(n) } diff --git a/gnovm/cmd/gno/tool_lint_test.go b/gnovm/cmd/gno/tool_lint_test.go index 85b625fa367..3f9e5cd59ba 100644 --- a/gnovm/cmd/gno/tool_lint_test.go +++ b/gnovm/cmd/gno/tool_lint_test.go @@ -10,49 +10,63 @@ func TestLintApp(t *testing.T) { { args: []string{"tool", "lint"}, errShouldBe: "flag: help requested", - }, { + }, + { args: []string{"tool", "lint", "../../tests/integ/run_main/"}, stderrShouldContain: "./../../tests/integ/run_main: gno.mod file not found in current or any parent directory (code=1)", errShouldBe: "exit code: 1", - }, { + }, + { args: []string{"tool", "lint", "../../tests/integ/undefined_variable_test/undefined_variables_test.gno"}, stderrShouldContain: "undefined_variables_test.gno:6:28: name toto not declared (code=2)", errShouldBe: "exit code: 1", - }, { + }, + { args: []string{"tool", "lint", "../../tests/integ/package_not_declared/main.gno"}, stderrShouldContain: "main.gno:4:2: name fmt not declared (code=2)", errShouldBe: "exit code: 1", - }, { + }, + { args: []string{"tool", "lint", "../../tests/integ/several-lint-errors/main.gno"}, - stderrShouldContain: "../../tests/integ/several-lint-errors/main.gno:5:5: expected ';', found example (code=2)\n../../tests/integ/several-lint-errors/main.gno:6", + stderrShouldContain: "../../tests/integ/several-lint-errors/main.gno:5:5: expected ';', found example (code=3)\n../../tests/integ/several-lint-errors/main.gno:6", errShouldBe: "exit code: 1", - }, { + }, + { args: []string{"tool", "lint", "../../tests/integ/several-files-multiple-errors/main.gno"}, stderrShouldContain: func() string { lines := []string{ - "../../tests/integ/several-files-multiple-errors/file2.gno:3:5: expected 'IDENT', found '{' (code=2)", - "../../tests/integ/several-files-multiple-errors/file2.gno:5:1: expected type, found '}' (code=2)", - "../../tests/integ/several-files-multiple-errors/main.gno:5:5: expected ';', found example (code=2)", - "../../tests/integ/several-files-multiple-errors/main.gno:6:2: expected '}', found 'EOF' (code=2)", + "../../tests/integ/several-files-multiple-errors/file2.gno:3:5: expected 'IDENT', found '{' (code=3)", + "../../tests/integ/several-files-multiple-errors/file2.gno:5:1: expected type, found '}' (code=3)", + "../../tests/integ/several-files-multiple-errors/main.gno:5:5: expected ';', found example (code=3)", + "../../tests/integ/several-files-multiple-errors/main.gno:6:2: expected '}', found 'EOF' (code=3)", } return strings.Join(lines, "\n") + "\n" }(), errShouldBe: "exit code: 1", - }, { + }, + { args: []string{"tool", "lint", "../../tests/integ/minimalist_gnomod/"}, // TODO: raise an error because there is a gno.mod, but no .gno files - }, { + }, + { args: []string{"tool", "lint", "../../tests/integ/invalid_module_name/"}, // TODO: raise an error because gno.mod is invalid - }, { + }, + { args: []string{"tool", "lint", "../../tests/integ/invalid_gno_file/"}, stderrShouldContain: "../../tests/integ/invalid_gno_file/invalid.gno:1:1: expected 'package', found packag (code=2)", errShouldBe: "exit code: 1", - }, { + }, + { args: []string{"tool", "lint", "../../tests/integ/typecheck_missing_return/"}, stderrShouldContain: "../../tests/integ/typecheck_missing_return/main.gno:5:1: missing return (code=4)", errShouldBe: "exit code: 1", }, + { + args: []string{"tool", "lint", "../../tests/integ/init/"}, + // stderr / stdout should be empty; the init function and statements + // should not be executed + }, // TODO: 'gno mod' is valid? // TODO: are dependencies valid? diff --git a/gnovm/cmd/gno/version.go b/gnovm/cmd/gno/version.go new file mode 100644 index 00000000000..f9b967d1c40 --- /dev/null +++ b/gnovm/cmd/gno/version.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + + "github.com/gnolang/gno/gnovm/pkg/version" + "github.com/gnolang/gno/tm2/pkg/commands" +) + +// newVersionCmd creates a new version command +func newGnoVersionCmd(io commands.IO) *commands.Command { + return commands.NewCommand( + commands.Metadata{ + Name: "version", + ShortUsage: "version", + ShortHelp: "display installed gno version", + }, + nil, + func(_ context.Context, args []string) error { + io.Println("gno version:", version.Version) + return nil + }, + ) +} diff --git a/gnovm/cmd/gno/version_test.go b/gnovm/cmd/gno/version_test.go new file mode 100644 index 00000000000..fab47319297 --- /dev/null +++ b/gnovm/cmd/gno/version_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "testing" + + "github.com/gnolang/gno/gnovm/pkg/version" +) + +func TestVersionApp(t *testing.T) { + originalVersion := version.Version + + t.Cleanup(func() { + version.Version = originalVersion + }) + + versionValues := []string{"chain/test4.2", "develop", "master"} + + testCases := make([]testMainCase, len(versionValues)) + for i, v := range versionValues { + testCases[i] = testMainCase{ + args: []string{"version"}, + stdoutShouldContain: "gno version: " + v, + } + } + + for i, testCase := range testCases { + t.Run(versionValues[i], func(t *testing.T) { + version.Version = versionValues[i] + testMainCaseRun(t, []testMainCase{testCase}) + }) + } +} diff --git a/gnovm/pkg/gnolang/debugger_test.go b/gnovm/pkg/gnolang/debugger_test.go index 926ff0595e6..a9e0a4834d5 100644 --- a/gnovm/pkg/gnolang/debugger_test.go +++ b/gnovm/pkg/gnolang/debugger_test.go @@ -39,7 +39,7 @@ func evalTest(debugAddr, in, file string) (out, err string) { err = strings.TrimSpace(strings.ReplaceAll(err, "../../tests/files/", "files/")) }() - _, testStore := test.Store(gnoenv.RootDir(), false, stdin, stdout, stderr) + _, testStore := test.Store(gnoenv.RootDir(), stdin, stdout, stderr) f := gnolang.MustReadFile(file) diff --git a/gnovm/pkg/gnolang/files_test.go b/gnovm/pkg/gnolang/files_test.go index 2c82f6d8f29..31f04087855 100644 --- a/gnovm/pkg/gnolang/files_test.go +++ b/gnovm/pkg/gnolang/files_test.go @@ -45,9 +45,9 @@ func TestFiles(t *testing.T) { Error: io.Discard, Sync: *withSync, } - o.BaseStore, o.TestStore = test.Store( - rootDir, true, - nopReader{}, o.WriterForStore(), io.Discard, + o.BaseStore, o.TestStore = test.StoreWithOptions( + rootDir, nopReader{}, o.WriterForStore(), io.Discard, + test.StoreOptions{WithExtern: true}, ) return o } diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 75d12ac5402..f7d2cf10f2c 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -454,6 +454,51 @@ func (m *Machine) RunFiles(fns ...*FileNode) { m.runInitFromUpdates(pv, updates) } +// PreprocessFiles runs Preprocess on the given files. It is used to detect +// compile-time errors in the package. +func (m *Machine) PreprocessFiles(pkgName, pkgPath string, fset *FileSet, save, withOverrides bool) (*PackageNode, *PackageValue) { + if !withOverrides { + if err := checkDuplicates(fset); err != nil { + panic(fmt.Errorf("running package %q: %w", pkgName, err)) + } + } + pn := NewPackageNode(Name(pkgName), pkgPath, fset) + pv := pn.NewPackage() + pb := pv.GetBlock(m.Store) + m.SetActivePackage(pv) + m.Store.SetBlockNode(pn) + PredefineFileSet(m.Store, pn, fset) + for _, fn := range fset.Files { + fn = Preprocess(m.Store, pn, fn).(*FileNode) + // After preprocessing, save blocknodes to store. + SaveBlockNodes(m.Store, fn) + // Make block for fn. + // Each file for each *PackageValue gets its own file *Block, + // with values copied over from each file's + // *FileNode.StaticBlock. + fb := m.Alloc.NewBlock(fn, pb) + fb.Values = make([]TypedValue, len(fn.StaticBlock.Values)) + copy(fb.Values, fn.StaticBlock.Values) + pv.AddFileBlock(fn.Name, fb) + } + // Get new values across all files in package. + pn.PrepareNewValues(pv) + // save package value. + var throwaway *Realm + if save { + // store new package values and types + throwaway = m.saveNewPackageValuesAndTypes() + if throwaway != nil { + m.Realm = throwaway + } + m.resavePackageValues(throwaway) + if throwaway != nil { + m.Realm = nil + } + } + return pn, pv +} + // Add files to the package's *FileSet and run decls in them. // This will also run each init function encountered. // Returns the updated typed values of package. diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index ca5834aa44e..0b86449b235 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -3366,18 +3366,42 @@ func getResultTypedValues(cx *CallExpr) []TypedValue { // NOTE: Generally, conversion happens in a separate step while leaving // composite exprs/nodes that contain constant expression nodes (e.g. const // exprs in the rhs of AssignStmts). +// +// Array-related expressions like `len` and `cap` are manually evaluated +// as constants, even if the array itself is not a constant. This evaluation +// is handled independently of the rest of the constant evaluation process, +// bypassing machine.EvalStatic. func evalConst(store Store, last BlockNode, x Expr) *ConstExpr { // TODO: some check or verification for ensuring x - // is constant? From the machine? - m := NewMachine(".dontcare", store) - m.PreprocessorMode = true + var cx *ConstExpr + if clx, ok := x.(*CallExpr); ok { + t := evalStaticTypeOf(store, last, clx.Args[0]) + if ar, ok := unwrapPointerType(baseOf(t)).(*ArrayType); ok { + fv := clx.Func.(*ConstExpr).V.(*FuncValue) + switch fv.Name { + case "cap", "len": + tv := TypedValue{T: IntType} + tv.SetInt(ar.Len) + cx = &ConstExpr{ + Source: x, + TypedValue: tv, + } + default: + panic(fmt.Sprintf("unexpected const func %s", fv.Name)) + } + } + } - cv := m.EvalStatic(last, x) - m.PreprocessorMode = false - m.Release() - cx := &ConstExpr{ - Source: x, - TypedValue: cv, + if cx == nil { + // is constant? From the machine? + m := NewMachine(".dontcare", store) + cv := m.EvalStatic(last, x) + m.PreprocessorMode = false + m.Release() + cx = &ConstExpr{ + Source: x, + TypedValue: cv, + } } cx.SetLine(x.GetLine()) cx.SetAttribute(ATTR_PREPROCESSED, true) diff --git a/gnovm/pkg/gnolang/type_check.go b/gnovm/pkg/gnolang/type_check.go index f96cb71e4b6..a79e9c43ecc 100644 --- a/gnovm/pkg/gnolang/type_check.go +++ b/gnovm/pkg/gnolang/type_check.go @@ -270,7 +270,7 @@ Main: switch { case fv.Name == "len": at := evalStaticTypeOf(store, last, currExpr.Args[0]) - if _, ok := baseOf(at).(*ArrayType); ok { + if _, ok := unwrapPointerType(baseOf(at)).(*ArrayType); ok { // ok break Main } @@ -278,7 +278,7 @@ Main: break Main case fv.Name == "cap": at := evalStaticTypeOf(store, last, currExpr.Args[0]) - if _, ok := baseOf(at).(*ArrayType); ok { + if _, ok := unwrapPointerType(baseOf(at)).(*ArrayType); ok { // ok break Main } diff --git a/gnovm/pkg/gnolang/types.go b/gnovm/pkg/gnolang/types.go index 374ac6d9150..8ac07162f10 100644 --- a/gnovm/pkg/gnolang/types.go +++ b/gnovm/pkg/gnolang/types.go @@ -1469,6 +1469,13 @@ func baseOf(t Type) Type { } } +func unwrapPointerType(t Type) Type { + if pt, ok := t.(*PointerType); ok { + return pt.Elem() + } + return t +} + // NOTE: it may be faster to switch on baseOf(). func (dt *DeclaredType) Kind() Kind { return dt.Base.Kind() diff --git a/gnovm/pkg/repl/repl.go b/gnovm/pkg/repl/repl.go index b0944d21646..fff80d672dc 100644 --- a/gnovm/pkg/repl/repl.go +++ b/gnovm/pkg/repl/repl.go @@ -125,7 +125,7 @@ func NewRepl(opts ...ReplOption) *Repl { r.stderr = &b r.storeFunc = func() gno.Store { - _, st := test.Store(gnoenv.RootDir(), false, r.stdin, r.stdout, r.stderr) + _, st := test.Store(gnoenv.RootDir(), r.stdin, r.stdout, r.stderr) return st } diff --git a/gnovm/pkg/test/filetest.go b/gnovm/pkg/test/filetest.go index 1934f429568..c24c014a9ba 100644 --- a/gnovm/pkg/test/filetest.go +++ b/gnovm/pkg/test/filetest.go @@ -103,6 +103,11 @@ func (opts *TestOptions) runFiletest(filename string, source []byte) (string, er // The Error directive (and many others) will have one trailing newline, // which is not in the output - so add it there. match(errDirective, result.Error+"\n") + } else if result.Output != "" { + outputDirective := dirs.First(DirectiveOutput) + if outputDirective == nil { + return "", fmt.Errorf("unexpected output:\n%s", result.Output) + } } else { err = m.CheckEmpty() if err != nil { diff --git a/gnovm/pkg/test/imports.go b/gnovm/pkg/test/imports.go index a8dd709e501..95302ecffb0 100644 --- a/gnovm/pkg/test/imports.go +++ b/gnovm/pkg/test/imports.go @@ -25,22 +25,56 @@ import ( storetypes "github.com/gnolang/gno/tm2/pkg/store/types" ) +type StoreOptions struct { + // WithExtern interprets imports of packages under "github.com/gnolang/gno/_test/" + // as imports under the directory in gnovm/tests/files/extern. + // This should only be used for GnoVM internal filetests (gnovm/tests/files). + WithExtern bool + + // PreprocessOnly instructs the PackageGetter to run the imported files using + // [gno.Machine.PreprocessFiles]. It avoids executing code for contexts + // which only intend to perform a type check, ie. `gno lint`. + PreprocessOnly bool +} + // NOTE: this isn't safe, should only be used for testing. func Store( rootDir string, - withExtern bool, stdin io.Reader, stdout, stderr io.Writer, ) ( baseStore storetypes.CommitStore, resStore gno.Store, ) { + return StoreWithOptions(rootDir, stdin, stdout, stderr, StoreOptions{}) +} + +// StoreWithOptions is a variant of [Store] which additionally accepts a +// [StoreOptions] argument. +func StoreWithOptions( + rootDir string, + stdin io.Reader, + stdout, stderr io.Writer, + opts StoreOptions, +) ( + baseStore storetypes.CommitStore, + resStore gno.Store, +) { + processMemPackage := func(m *gno.Machine, memPkg *gnovm.MemPackage, save bool) (*gno.PackageNode, *gno.PackageValue) { + return m.RunMemPackage(memPkg, save) + } + if opts.PreprocessOnly { + processMemPackage = func(m *gno.Machine, memPkg *gnovm.MemPackage, save bool) (*gno.PackageNode, *gno.PackageValue) { + m.Store.AddMemPackage(memPkg) + return m.PreprocessFiles(memPkg.Name, memPkg.Path, gno.ParseMemPackage(memPkg), save, false) + } + } getPackage := func(pkgPath string, store gno.Store) (pn *gno.PackageNode, pv *gno.PackageValue) { if pkgPath == "" { panic(fmt.Sprintf("invalid zero package path in testStore().pkgGetter")) } - if withExtern { + if opts.WithExtern { // if _test package... const testPath = "github.com/gnolang/gno/_test/" if strings.HasPrefix(pkgPath, testPath) { @@ -54,7 +88,7 @@ func Store( Store: store, Context: ctx, }) - return m2.RunMemPackage(memPkg, true) + return processMemPackage(m2, memPkg, true) } } @@ -129,7 +163,7 @@ func Store( } // Load normal stdlib. - pn, pv = loadStdlib(rootDir, pkgPath, store, stdout) + pn, pv = loadStdlib(rootDir, pkgPath, store, stdout, opts.PreprocessOnly) if pn != nil { return } @@ -150,8 +184,7 @@ func Store( Store: store, Context: ctx, }) - pn, pv = m2.RunMemPackage(memPkg, true) - return + return processMemPackage(m2, memPkg, true) } return nil, nil } @@ -164,7 +197,7 @@ func Store( return } -func loadStdlib(rootDir, pkgPath string, store gno.Store, stdout io.Writer) (*gno.PackageNode, *gno.PackageValue) { +func loadStdlib(rootDir, pkgPath string, store gno.Store, stdout io.Writer, preprocessOnly bool) (*gno.PackageNode, *gno.PackageValue) { dirs := [...]string{ // Normal stdlib path. filepath.Join(rootDir, "gnovm", "stdlibs", pkgPath), @@ -202,6 +235,11 @@ func loadStdlib(rootDir, pkgPath string, store gno.Store, stdout io.Writer) (*gn Output: stdout, Store: store, }) + if preprocessOnly { + m2.Store.AddMemPackage(memPkg) + return m2.PreprocessFiles(memPkg.Name, memPkg.Path, gno.ParseMemPackage(memPkg), true, true) + } + // TODO: make this work when using gno lint. return m2.RunMemPackageWithOverrides(memPkg, true) } diff --git a/gnovm/pkg/test/test.go b/gnovm/pkg/test/test.go index fc4814aa478..71ec3bb2568 100644 --- a/gnovm/pkg/test/test.go +++ b/gnovm/pkg/test/test.go @@ -139,10 +139,7 @@ func NewTestOptions(rootDir string, stdin io.Reader, stdout, stderr io.Writer) * Output: stdout, Error: stderr, } - opts.BaseStore, opts.TestStore = Store( - rootDir, false, - stdin, opts.WriterForStore(), stderr, - ) + opts.BaseStore, opts.TestStore = Store(rootDir, stdin, opts.WriterForStore(), stderr) return opts } diff --git a/gnovm/pkg/version/version.go b/gnovm/pkg/version/version.go new file mode 100644 index 00000000000..933d4fac3e5 --- /dev/null +++ b/gnovm/pkg/version/version.go @@ -0,0 +1,3 @@ +package version + +var Version = "develop" diff --git a/gnovm/tests/files/addressable_5.gno b/gnovm/tests/files/addressable_5.gno index 800cc744458..fa39ef42841 100644 --- a/gnovm/tests/files/addressable_5.gno +++ b/gnovm/tests/files/addressable_5.gno @@ -9,3 +9,6 @@ func main() { le := &binary.LittleEndian println(&le.AppendUint16(b, 0)[0]) } + +// Output: +// &(0 uint8) diff --git a/gnovm/tests/files/const51.gno b/gnovm/tests/files/const51.gno new file mode 100644 index 00000000000..b00748b0ec7 --- /dev/null +++ b/gnovm/tests/files/const51.gno @@ -0,0 +1,20 @@ +package main + +type T1 struct { + x [2]string +} + +type T2 struct { + x *[2]string +} + +func main() { + t1 := T1{x: [2]string{"a", "b"}} + t2 := T2{x: &[2]string{"a", "b"}} + const c1 = len(t1.x) + const c2 = len(t2.x) + println(c1, c2) +} + +// Output: +// 2 2 diff --git a/gnovm/tests/files/const52.gno b/gnovm/tests/files/const52.gno new file mode 100644 index 00000000000..c213faeb12b --- /dev/null +++ b/gnovm/tests/files/const52.gno @@ -0,0 +1,11 @@ +package main + +func main() { + s := make([][2]string, 1) // Slice with length 1 + s[0] = [2]string{"a", "b"} // Assign value to s[0] + const r = len(s[0]) + println(r) // Prints: 2 +} + +// Output: +// 2 diff --git a/gnovm/tests/files/type_alias.gno b/gnovm/tests/files/type_alias.gno index e95c54126ec..09918f6d591 100644 --- a/gnovm/tests/files/type_alias.gno +++ b/gnovm/tests/files/type_alias.gno @@ -6,7 +6,8 @@ import "gno.land/p/demo/uassert" type TestingT = uassert.TestingT func main() { - println(TestingT) + println("ok") } -// No need for output; not panicking is passing. +// Output: +// ok diff --git a/gnovm/tests/files/types/eql_0f49.gno b/gnovm/tests/files/types/eql_0f49.gno index b5a4bf4ed05..b4d6f7e3972 100644 --- a/gnovm/tests/files/types/eql_0f49.gno +++ b/gnovm/tests/files/types/eql_0f49.gno @@ -14,6 +14,8 @@ func main() { } +// Output: +// true // true // true // true diff --git a/gnovm/tests/integ/init/gno.mod b/gnovm/tests/integ/init/gno.mod new file mode 100644 index 00000000000..28c7e51b750 --- /dev/null +++ b/gnovm/tests/integ/init/gno.mod @@ -0,0 +1 @@ +module gno.land/r/demo/init diff --git a/gnovm/tests/integ/init/main.gno b/gnovm/tests/integ/init/main.gno new file mode 100644 index 00000000000..88cfafb9f24 --- /dev/null +++ b/gnovm/tests/integ/init/main.gno @@ -0,0 +1,10 @@ +package main + +var _ = func() int { + println("HELLO HELLO!!") + return 1 +}() + +func init() { + println("HELLO WORLD!") +} diff --git a/go.mod b/go.mod index ce58b8f7998..027ba6359bc 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b github.com/stretchr/testify v1.10.0 github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 + github.com/valyala/bytebufferpool v1.0.0 github.com/yuin/goldmark v1.7.8 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc go.etcd.io/bbolt v1.3.11 diff --git a/go.sum b/go.sum index 046d9c8c75a..5fd4cddd627 100644 --- a/go.sum +++ b/go.sum @@ -148,6 +148,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= diff --git a/misc/autocounterd/go.mod b/misc/autocounterd/go.mod index 730a3d901b7..972975d4fb0 100644 --- a/misc/autocounterd/go.mod +++ b/misc/autocounterd/go.mod @@ -29,6 +29,7 @@ require ( github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/zondax/hid v0.9.2 // indirect github.com/zondax/ledger-go v0.14.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/misc/autocounterd/go.sum b/misc/autocounterd/go.sum index 3d0eae7661b..6d6d87fa01a 100644 --- a/misc/autocounterd/go.sum +++ b/misc/autocounterd/go.sum @@ -133,6 +133,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= github.com/zondax/hid v0.9.2/go.mod h1:l5wttcP0jwtdLjqjMMWFVEE7d1zO0jvSPA9OPZxWpEM= github.com/zondax/ledger-go v0.14.3 h1:wEpJt2CEcBJ428md/5MgSLsXLBos98sBOyxNmCjfUCw= diff --git a/misc/loop/cmd/snapshotter.go b/misc/loop/cmd/snapshotter.go index 0173f9aad03..eef4be36d2a 100644 --- a/misc/loop/cmd/snapshotter.go +++ b/misc/loop/cmd/snapshotter.go @@ -18,7 +18,7 @@ import ( "github.com/docker/docker/client" "github.com/docker/go-connections/nat" "github.com/gnolang/tx-archive/backup" - "github.com/gnolang/tx-archive/backup/client/http" + "github.com/gnolang/tx-archive/backup/client/rpc" "github.com/gnolang/tx-archive/backup/writer/standard" ) @@ -202,6 +202,10 @@ func (s snapshotter) backupTXs(ctx context.Context, rpcURL string) error { cfg.FromBlock = 1 cfg.Watch = false + // We want to skip failed txs on the Portal Loop reset, + // because they might (unexpectedly) succeed + cfg.SkipFailedTx = true + instanceBackupFile, err := os.Create(s.instanceBackupFile) if err != nil { return err @@ -211,7 +215,7 @@ func (s snapshotter) backupTXs(ctx context.Context, rpcURL string) error { w := standard.NewWriter(instanceBackupFile) // Create the tx-archive backup service - c, err := http.NewClient(rpcURL) + c, err := rpc.NewHTTPClient(rpcURL) if err != nil { return fmt.Errorf("could not create tx-archive client, %w", err) } diff --git a/misc/loop/go.mod b/misc/loop/go.mod index c72101c7c1e..4c5a3f41839 100644 --- a/misc/loop/go.mod +++ b/misc/loop/go.mod @@ -8,7 +8,7 @@ require ( github.com/docker/docker v25.0.6+incompatible github.com/docker/go-connections v0.4.0 github.com/gnolang/gno v0.1.0-nightly.20240627 - github.com/gnolang/tx-archive v0.4.2 + github.com/gnolang/tx-archive v0.5.0 github.com/prometheus/client_golang v1.17.0 github.com/sirupsen/logrus v1.9.3 ) @@ -56,6 +56,7 @@ require ( github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect go.etcd.io/bbolt v1.3.11 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect diff --git a/misc/loop/go.sum b/misc/loop/go.sum index 634dbdac082..c5aed820f5e 100644 --- a/misc/loop/go.sum +++ b/misc/loop/go.sum @@ -70,8 +70,8 @@ github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHqu github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gnolang/tx-archive v0.4.2 h1:xBBqLLKY9riv9yxpQgVhItCWxIji2rX6xNFmCY1cEOQ= -github.com/gnolang/tx-archive v0.4.2/go.mod h1:AGUBGO+DCLuKL80a1GJRnpcJ5gxVd9L4jEJXQB9uXp4= +github.com/gnolang/tx-archive v0.5.0 h1:npM+TfM3ufF2nz1V6hq+RLkCklPbADRZXBjiyPxXVu4= +github.com/gnolang/tx-archive v0.5.0/go.mod h1:thbXpyYT57ITGABl3hH4ftLSdO8eXaPFPi5hl6jZ2UE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -179,6 +179,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= diff --git a/misc/stdlib_diff/Makefile b/misc/stdlib_diff/Makefile index 439af22c586..32dcf95a2ec 100644 --- a/misc/stdlib_diff/Makefile +++ b/misc/stdlib_diff/Makefile @@ -1,7 +1,9 @@ all: clean gen +GOROOT_SAVE ?= $(shell go env GOROOT) + gen: - go run . -src $(GOROOT)/src -dst ../../gnovm/stdlibs -out ./stdlib_diff + go run . -src $(GOROOT_SAVE)/src -dst ../../gnovm/stdlibs -out ./stdlib_diff clean: rm -rf stdlib_diff diff --git a/misc/stdlib_diff/README.md b/misc/stdlib_diff/README.md index 32c3cbcd93d..47d05a0373b 100644 --- a/misc/stdlib_diff/README.md +++ b/misc/stdlib_diff/README.md @@ -1,6 +1,6 @@ # stdlibs_diff -stdlibs_diff is a tool that generates an html report indicating differences between gno standard libraries and go standrad libraries +stdlibs_diff is a tool that generates an html report indicating differences between gno standard libraries and go standard libraries. ## Usage @@ -27,4 +27,4 @@ Compare the `gno` standard libraries the `go` standard libraries ## Tips -An index.html is generated at the root of the report location. Utilize it to navigate easily through the report. \ No newline at end of file +An index.html is generated at the root of the report location. Utilize it to navigate easily through the report. diff --git a/tm2/pkg/amino/amino.go b/tm2/pkg/amino/amino.go index 262f5d9a54e..b8942c49029 100644 --- a/tm2/pkg/amino/amino.go +++ b/tm2/pkg/amino/amino.go @@ -219,7 +219,8 @@ func (cdc *Codec) MarshalSized(o interface{}) ([]byte, error) { cdc.doAutoseal() // Write the bytes here. - buf := new(bytes.Buffer) + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) // Write the bz without length-prefixing. bz, err := cdc.Marshal(o) @@ -239,7 +240,7 @@ func (cdc *Codec) MarshalSized(o interface{}) ([]byte, error) { return nil, err } - return buf.Bytes(), nil + return copyBytes(buf.Bytes()), nil } // MarshalSizedWriter writes the bytes as would be returned from @@ -271,8 +272,8 @@ func (cdc *Codec) MarshalAnySized(o interface{}) ([]byte, error) { cdc.doAutoseal() // Write the bytes here. - buf := new(bytes.Buffer) - + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) // Write the bz without length-prefixing. bz, err := cdc.MarshalAny(o) if err != nil { @@ -291,7 +292,7 @@ func (cdc *Codec) MarshalAnySized(o interface{}) ([]byte, error) { return nil, err } - return buf.Bytes(), nil + return copyBytes(buf.Bytes()), nil } func (cdc *Codec) MustMarshalAnySized(o interface{}) []byte { @@ -357,7 +358,9 @@ func (cdc *Codec) MarshalReflect(o interface{}) ([]byte, error) { // Encode Amino:binary bytes. var bz []byte - buf := new(bytes.Buffer) + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) + rt := rv.Type() info, err := cdc.getTypeInfoWLock(rt) if err != nil { @@ -377,7 +380,7 @@ func (cdc *Codec) MarshalReflect(o interface{}) ([]byte, error) { if err = cdc.writeFieldIfNotEmpty(buf, 1, info, FieldOptions{}, FieldOptions{}, rv, writeEmpty); err != nil { return nil, err } - bz = buf.Bytes() + bz = copyBytes(buf.Bytes()) } else { // The passed in BinFieldNum is only relevant for when the type is to // be encoded unpacked (elements are Typ3_ByteLength). In that case, @@ -387,7 +390,7 @@ func (cdc *Codec) MarshalReflect(o interface{}) ([]byte, error) { if err != nil { return nil, err } - bz = buf.Bytes() + bz = copyBytes(buf.Bytes()) } // If bz is empty, prefer nil. if len(bz) == 0 { @@ -443,16 +446,23 @@ func (cdc *Codec) MarshalAny(o interface{}) ([]byte, error) { } // Encode as interface. - buf := new(bytes.Buffer) + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) err = cdc.encodeReflectBinaryInterface(buf, iinfo, reflect.ValueOf(&ivar).Elem(), FieldOptions{}, true) if err != nil { return nil, err } - bz := buf.Bytes() + bz := copyBytes(buf.Bytes()) return bz, nil } +func copyBytes(bz []byte) []byte { + cp := make([]byte, len(bz)) + copy(cp, bz) + return cp +} + // Panics if error. func (cdc *Codec) MustMarshalAny(o interface{}) []byte { bz, err := cdc.MarshalAny(o) @@ -764,7 +774,8 @@ func (cdc *Codec) JSONMarshal(o interface{}) ([]byte, error) { return []byte("null"), nil } rt := rv.Type() - w := new(bytes.Buffer) + w := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(w) info, err := cdc.getTypeInfoWLock(rt) if err != nil { return nil, err @@ -772,7 +783,8 @@ func (cdc *Codec) JSONMarshal(o interface{}) ([]byte, error) { if err = cdc.encodeReflectJSON(w, info, rv, FieldOptions{}); err != nil { return nil, err } - return w.Bytes(), nil + + return copyBytes(w.Bytes()), nil } func (cdc *Codec) MarshalJSONAny(o interface{}) ([]byte, error) { @@ -802,12 +814,14 @@ func (cdc *Codec) MarshalJSONAny(o interface{}) ([]byte, error) { } // Encode as interface. - buf := new(bytes.Buffer) + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) + err = cdc.encodeReflectJSONInterface(buf, iinfo, reflect.ValueOf(&ivar).Elem(), FieldOptions{}) if err != nil { return nil, err } - bz := buf.Bytes() + bz := copyBytes(buf.Bytes()) return bz, nil } @@ -863,12 +877,12 @@ func (cdc *Codec) MarshalJSONIndent(o interface{}, prefix, indent string) ([]byt if err != nil { return nil, err } + var out bytes.Buffer - err = json.Indent(&out, bz, prefix, indent) - if err != nil { + if err := json.Indent(&out, bz, prefix, indent); err != nil { return nil, err } - return out.Bytes(), nil + return copyBytes(out.Bytes()), nil } // ---------------------------------------- diff --git a/tm2/pkg/amino/binary_encode.go b/tm2/pkg/amino/binary_encode.go index 426cc520604..45758329284 100644 --- a/tm2/pkg/amino/binary_encode.go +++ b/tm2/pkg/amino/binary_encode.go @@ -1,12 +1,13 @@ package amino import ( - "bytes" "encoding/binary" "errors" "fmt" "io" "reflect" + + "github.com/valyala/bytebufferpool" ) const beOptionByte = 0x01 @@ -209,6 +210,8 @@ func (cdc *Codec) encodeReflectBinary(w io.Writer, info *TypeInfo, rv reflect.Va return err } +var poolBytesBuffer = new(bytebufferpool.Pool) + func (cdc *Codec) encodeReflectBinaryInterface(w io.Writer, iinfo *TypeInfo, rv reflect.Value, fopts FieldOptions, bare bool, ) (err error) { @@ -250,7 +253,9 @@ func (cdc *Codec) encodeReflectBinaryInterface(w io.Writer, iinfo *TypeInfo, rv // For Proto3 compatibility, encode interfaces as google.protobuf.Any // Write field #1, TypeURL - buf := bytes.NewBuffer(nil) + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) + { fnum := uint32(1) err = encodeFieldNumberAndTyp3(buf, fnum, Typ3ByteLength) @@ -269,7 +274,9 @@ func (cdc *Codec) encodeReflectBinaryInterface(w io.Writer, iinfo *TypeInfo, rv { // google.protobuf.Any values must be a struct, or an unpacked list which // is indistinguishable from a struct. - buf2 := bytes.NewBuffer(nil) + buf2 := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf2) + if !cinfo.IsStructOrUnpacked(fopts) { writeEmpty := false // Encode with an implicit struct, with a single field with number 1. @@ -356,7 +363,8 @@ func (cdc *Codec) encodeReflectBinaryList(w io.Writer, info *TypeInfo, rv reflec // Proto3 byte-length prefixing incurs alloc cost on the encoder. // Here we incur it for unpacked form for ease of dev. - buf := bytes.NewBuffer(nil) + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) // If elem is not already a ByteLength type, write in packed form. // This is a Proto wart due to Proto backwards compatibility issues. @@ -393,6 +401,9 @@ func (cdc *Codec) encodeReflectBinaryList(w io.Writer, info *TypeInfo, rv reflec einfo.Elem.ReprType.Type.Kind() != reflect.Uint8 && einfo.Elem.ReprType.GetTyp3(fopts) != Typ3ByteLength + elemBuf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(elemBuf) + // Write elems in unpacked form. for i := 0; i < rv.Len(); i++ { // Write elements as repeated fields of the parent struct. @@ -431,20 +442,21 @@ func (cdc *Codec) encodeReflectBinaryList(w io.Writer, info *TypeInfo, rv reflec // form) are represented as lists of implicit structs. if writeImplicit { // Write field key for Value field of implicit struct. - buf2 := new(bytes.Buffer) - err = encodeFieldNumberAndTyp3(buf2, 1, Typ3ByteLength) + + err = encodeFieldNumberAndTyp3(elemBuf, 1, Typ3ByteLength) if err != nil { return } // Write field value of implicit struct to buf2. efopts := fopts efopts.BinFieldNum = 0 // dontcare - err = cdc.encodeReflectBinary(buf2, einfo, derv, efopts, false, 0) + err = cdc.encodeReflectBinary(elemBuf, einfo, derv, efopts, false, 0) if err != nil { return } // Write implicit struct to buf. - err = EncodeByteSlice(buf, buf2.Bytes()) + err = EncodeByteSlice(buf, elemBuf.Bytes()) + elemBuf.Reset() if err != nil { return } @@ -497,7 +509,8 @@ func (cdc *Codec) encodeReflectBinaryStruct(w io.Writer, info *TypeInfo, rv refl // Proto3 incurs a cost in writing non-root structs. // Here we incur it for root structs as well for ease of dev. - buf := bytes.NewBuffer(nil) + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) for _, field := range info.Fields { // Get type info for field. @@ -553,7 +566,7 @@ func encodeFieldNumberAndTyp3(w io.Writer, num uint32, typ Typ3) (err error) { } func (cdc *Codec) writeFieldIfNotEmpty( - buf *bytes.Buffer, + buf *bytebufferpool.ByteBuffer, fieldNum uint32, finfo *TypeInfo, structsFopts FieldOptions, // the wrapping struct's FieldOptions if any @@ -579,7 +592,7 @@ func (cdc *Codec) writeFieldIfNotEmpty( if !isWriteEmpty && lBeforeValue == lAfterValue-1 && buf.Bytes()[buf.Len()-1] == 0x00 { // rollback typ3/fieldnum and last byte if // not a pointer and empty: - buf.Truncate(lBeforeKey) + buf.Set(buf.Bytes()[:lBeforeKey]) } return nil } diff --git a/tm2/pkg/amino/codec.go b/tm2/pkg/amino/codec.go index 3fa7634e3ad..ba24f49a808 100644 --- a/tm2/pkg/amino/codec.go +++ b/tm2/pkg/amino/codec.go @@ -1,7 +1,6 @@ package amino import ( - "bytes" "fmt" "io" "reflect" @@ -113,7 +112,9 @@ func (info *TypeInfo) String() string { // before it's fully populated. return "" } - buf := new(bytes.Buffer) + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) + buf.Write([]byte("TypeInfo{")) buf.Write([]byte(fmt.Sprintf("Type:%v,", info.Type))) if info.ConcreteInfo.Registered { diff --git a/tm2/pkg/amino/json_encode.go b/tm2/pkg/amino/json_encode.go index 113c3486565..99e1b445917 100644 --- a/tm2/pkg/amino/json_encode.go +++ b/tm2/pkg/amino/json_encode.go @@ -1,7 +1,6 @@ package amino import ( - "bytes" "encoding/json" "fmt" "io" @@ -156,7 +155,9 @@ func (cdc *Codec) encodeReflectJSONInterface(w io.Writer, iinfo *TypeInfo, rv re } // Write Value to buffer - buf := new(bytes.Buffer) + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) + cdc.encodeReflectJSON(buf, cinfo, crv, fopts) value := buf.Bytes() if len(value) == 0 { diff --git a/tm2/pkg/amino/wellknown.go b/tm2/pkg/amino/wellknown.go index 7720c2894d9..4053c23e893 100644 --- a/tm2/pkg/amino/wellknown.go +++ b/tm2/pkg/amino/wellknown.go @@ -3,7 +3,6 @@ package amino // NOTE: We must not depend on protubuf libraries for serialization. import ( - "bytes" "fmt" "io" "reflect" @@ -342,7 +341,9 @@ func encodeReflectBinaryWellKnown(w io.Writer, info *TypeInfo, rv reflect.Value, } // Maybe recurse with length-prefixing. if !bare { - buf := bytes.NewBuffer(nil) + buf := poolBytesBuffer.Get() + defer poolBytesBuffer.Put(buf) + ok, err = encodeReflectBinaryWellKnown(buf, info, rv, fopts, true) if err != nil { return false, err diff --git a/tm2/pkg/bft/node/node.go b/tm2/pkg/bft/node/node.go index 08f1b3c58f9..7f16d6780c7 100644 --- a/tm2/pkg/bft/node/node.go +++ b/tm2/pkg/bft/node/node.go @@ -12,8 +12,6 @@ import ( "sync" "time" - goErrors "errors" - "github.com/gnolang/gno/tm2/pkg/bft/appconn" "github.com/gnolang/gno/tm2/pkg/bft/state/eventstore/file" "github.com/gnolang/gno/tm2/pkg/p2p/conn" @@ -604,12 +602,10 @@ func (n *Node) OnStart() error { } // Start the transport. - lAddr := n.config.P2P.ExternalAddress - if lAddr == "" { - lAddr = n.config.P2P.ListenAddress - } + // The listen address for the transport needs to be an address within reach of the machine NIC + listenAddress := p2pTypes.NetAddressString(n.nodeKey.ID(), n.config.P2P.ListenAddress) - addr, err := p2pTypes.NewNetAddressFromString(p2pTypes.NetAddressString(n.nodeKey.ID(), lAddr)) + addr, err := p2pTypes.NewNetAddressFromString(listenAddress) if err != nil { return fmt.Errorf("unable to parse network address, %w", err) } @@ -903,7 +899,7 @@ func makeNodeInfo( nodeInfo := p2pTypes.NodeInfo{ VersionSet: vset, - PeerID: nodeKey.ID(), + NetAddress: nil, // The shared address depends on the configuration Network: genDoc.ChainID, Version: version.Version, Channels: []byte{ @@ -918,13 +914,44 @@ func makeNodeInfo( }, } + // Make sure the discovery channel is shared with peers + // in case peer discovery is enabled if config.P2P.PeerExchange { nodeInfo.Channels = append(nodeInfo.Channels, discovery.Channel) } + // Grab the supplied listen address. + // This address needs to be valid, but it can be unspecified. + // If the listen address is unspecified (port / IP unbound), + // then this address cannot be used by peers for dialing + addr, err := p2pTypes.NewNetAddressFromString( + p2pTypes.NetAddressString(nodeKey.ID(), config.P2P.ListenAddress), + ) + if err != nil { + return p2pTypes.NodeInfo{}, fmt.Errorf("unable to parse network address, %w", err) + } + + // Use the transport listen address as the advertised address + nodeInfo.NetAddress = addr + + // Prepare the advertised dial address (if any) + // for the node, which other peers can use to dial + if config.P2P.ExternalAddress != "" { + addr, err = p2pTypes.NewNetAddressFromString( + p2pTypes.NetAddressString( + nodeKey.ID(), + config.P2P.ExternalAddress, + ), + ) + if err != nil { + return p2pTypes.NodeInfo{}, fmt.Errorf("invalid p2p external address: %w", err) + } + + nodeInfo.NetAddress = addr + } + // Validate the node info - err := nodeInfo.Validate() - if err != nil && !goErrors.Is(err, p2pTypes.ErrUnspecifiedIP) { + if err := nodeInfo.Validate(); err != nil { return p2pTypes.NodeInfo{}, fmt.Errorf("unable to validate node info, %w", err) } diff --git a/tm2/pkg/bft/rpc/lib/server/handlers.go b/tm2/pkg/bft/rpc/lib/server/handlers.go index 9e10596a975..b91db806342 100644 --- a/tm2/pkg/bft/rpc/lib/server/handlers.go +++ b/tm2/pkg/bft/rpc/lib/server/handlers.go @@ -145,6 +145,11 @@ func makeJSONRPCHandler(funcMap map[string]*RPCFunc, logger *slog.Logger) http.H requests types.RPCRequests responses types.RPCResponses ) + + // isRPCRequestArray is used to determine if the incoming payload is an array of requests. + // This flag helps decide whether to return an array of responses (for batch requests) or a single response. + isRPCRequestArray := true + if err := json.Unmarshal(b, &requests); err != nil { // next, try to unmarshal as a single request var request types.RPCRequest @@ -153,6 +158,7 @@ func makeJSONRPCHandler(funcMap map[string]*RPCFunc, logger *slog.Logger) http.H return } requests = []types.RPCRequest{request} + isRPCRequestArray = false } for _, request := range requests { @@ -191,9 +197,16 @@ func makeJSONRPCHandler(funcMap map[string]*RPCFunc, logger *slog.Logger) http.H } responses = append(responses, types.NewRPCSuccessResponse(request.ID, result)) } - if len(responses) > 0 { + if len(responses) == 0 { + return + } + + if isRPCRequestArray { WriteRPCResponseArrayHTTP(w, responses) + return } + + WriteRPCResponseHTTP(w, responses[0]) } } diff --git a/tm2/pkg/bft/rpc/lib/server/handlers_test.go b/tm2/pkg/bft/rpc/lib/server/handlers_test.go index f6572be7e0a..dde2cf1e327 100644 --- a/tm2/pkg/bft/rpc/lib/server/handlers_test.go +++ b/tm2/pkg/bft/rpc/lib/server/handlers_test.go @@ -171,6 +171,12 @@ func TestRPCNotificationInBatch(t *testing.T) { ]`, 1, }, + { + `[ + {"jsonrpc": "2.0","method":"c","id":"abc","params":["a","10"]} + ]`, + 1, + }, { `[ {"jsonrpc": "2.0","id": ""}, @@ -198,21 +204,8 @@ func TestRPCNotificationInBatch(t *testing.T) { // try to unmarshal an array first err = json.Unmarshal(blob, &responses) if err != nil { - // if we were actually expecting an array, but got an error - if tt.expectCount > 1 { - t.Errorf("#%d: expected an array, couldn't unmarshal it\nblob: %s", i, blob) - continue - } else { - // we were expecting an error here, so let's unmarshal a single response - var response types.RPCResponse - err = json.Unmarshal(blob, &response) - if err != nil { - t.Errorf("#%d: expected successful parsing of an RPCResponse\nblob: %s", i, blob) - continue - } - // have a single-element result - responses = types.RPCResponses{response} - } + t.Errorf("#%d: expected an array, couldn't unmarshal it\nblob: %s", i, blob) + continue } if tt.expectCount != len(responses) { t.Errorf("#%d: expected %d response(s), but got %d\nblob: %s", i, tt.expectCount, len(responses), blob) diff --git a/tm2/pkg/bft/rpc/lib/server/http_server.go b/tm2/pkg/bft/rpc/lib/server/http_server.go index a4e535160b5..a5cec3d5c81 100644 --- a/tm2/pkg/bft/rpc/lib/server/http_server.go +++ b/tm2/pkg/bft/rpc/lib/server/http_server.go @@ -119,18 +119,14 @@ func WriteRPCResponseHTTP(w http.ResponseWriter, res types.RPCResponse) { // can write arrays of responses for batched request/response interactions via // the JSON RPC. func WriteRPCResponseArrayHTTP(w http.ResponseWriter, res types.RPCResponses) { - if len(res) == 1 { - WriteRPCResponseHTTP(w, res[0]) - } else { - jsonBytes, err := json.MarshalIndent(res, "", " ") - if err != nil { - panic(err) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - if _, err := w.Write(jsonBytes); err != nil { - panic(err) - } + jsonBytes, err := json.MarshalIndent(res, "", " ") + if err != nil { + panic(err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + if _, err := w.Write(jsonBytes); err != nil { + panic(err) } } diff --git a/tm2/pkg/crypto/mock/mock.go b/tm2/pkg/crypto/mock/mock.go index 9ea1c5d66dc..b3fe8c5e69f 100644 --- a/tm2/pkg/crypto/mock/mock.go +++ b/tm2/pkg/crypto/mock/mock.go @@ -42,7 +42,7 @@ func (privKey PrivKeyMock) Equals(other crypto.PrivKey) bool { func GenPrivKey() PrivKeyMock { randstr := random.RandStr(12) - return PrivKeyMock([]byte(randstr)) + return []byte(randstr) } // ------------------------------------- diff --git a/tm2/pkg/internal/p2p/p2p.go b/tm2/pkg/internal/p2p/p2p.go index 1e650e0cd25..0c8f1529b85 100644 --- a/tm2/pkg/internal/p2p/p2p.go +++ b/tm2/pkg/internal/p2p/p2p.go @@ -70,12 +70,12 @@ func MakeConnectedPeers( VersionSet: versionset.VersionSet{ versionset.VersionInfo{Name: "p2p", Version: "v0.0.0"}, }, - PeerID: key.ID(), - Network: "testing", - Software: "p2ptest", - Version: "v1.2.3-rc.0-deadbeef", - Channels: cfg.Channels, - Moniker: fmt.Sprintf("node-%d", index), + NetAddress: addr, + Network: "testing", + Software: "p2ptest", + Version: "v1.2.3-rc.0-deadbeef", + Channels: cfg.Channels, + Moniker: fmt.Sprintf("node-%d", index), Other: p2pTypes.NodeInfoOther{ TxIndex: "off", RPCAddress: fmt.Sprintf("127.0.0.1:%d", 0), @@ -231,7 +231,7 @@ func (mp *Peer) TrySend(_ byte, _ []byte) bool { return true } func (mp *Peer) Send(_ byte, _ []byte) bool { return true } func (mp *Peer) NodeInfo() p2pTypes.NodeInfo { return p2pTypes.NodeInfo{ - PeerID: mp.id, + NetAddress: mp.addr, } } func (mp *Peer) Status() conn.ConnectionStatus { return conn.ConnectionStatus{} } diff --git a/tm2/pkg/p2p/discovery/discovery.go b/tm2/pkg/p2p/discovery/discovery.go index d884b118c75..7a9da3726c0 100644 --- a/tm2/pkg/p2p/discovery/discovery.go +++ b/tm2/pkg/p2p/discovery/discovery.go @@ -160,7 +160,7 @@ func (r *Reactor) Receive(chID byte, peer p2p.PeerConn, msgBytes []byte) { // Validate the message if err := msg.ValidateBasic(); err != nil { - r.Logger.Error("unable to validate discovery message", "err", err) + r.Logger.Warn("unable to validate discovery message", "err", err) return } @@ -168,7 +168,7 @@ func (r *Reactor) Receive(chID byte, peer p2p.PeerConn, msgBytes []byte) { switch msg := msg.(type) { case *Request: if err := r.handleDiscoveryRequest(peer); err != nil { - r.Logger.Error("unable to handle discovery request", "err", err) + r.Logger.Warn("unable to handle discovery request", "err", err) } case *Response: // Make the peers available for dialing on the switch @@ -186,9 +186,21 @@ func (r *Reactor) handleDiscoveryRequest(peer p2p.PeerConn) error { peers = make([]*types.NetAddress, 0, len(localPeers)) ) - // Exclude the private peers from being shared + // Exclude the private peers from being shared, + // as well as peers who are not dialable localPeers = slices.DeleteFunc(localPeers, func(p p2p.PeerConn) bool { - return p.IsPrivate() + var ( + // Private peers are peers whose information is kept private to the node + privatePeer = p.IsPrivate() + // The reason we don't validate the net address with .Routable() + // is because of legacy logic that supports local loopbacks as advertised + // peer addresses. Introducing a .Routable() constraint will filter all + // local loopback addresses shared by peers, and will cause local deployments + // (and unit test deployments) to break and require additional setup + invalidDialAddress = p.NodeInfo().DialAddress().Validate() != nil + ) + + return privatePeer || invalidDialAddress }) // Check if there is anything to share, @@ -207,7 +219,8 @@ func (r *Reactor) handleDiscoveryRequest(peer p2p.PeerConn) error { } for _, p := range localPeers { - peers = append(peers, p.SocketAddr()) + // Make sure only routable peers are shared + peers = append(peers, p.NodeInfo().DialAddress()) } // Create the response, and marshal diff --git a/tm2/pkg/p2p/discovery/discovery_test.go b/tm2/pkg/p2p/discovery/discovery_test.go index 17404e6039a..91741c648db 100644 --- a/tm2/pkg/p2p/discovery/discovery_test.go +++ b/tm2/pkg/p2p/discovery/discovery_test.go @@ -166,7 +166,7 @@ func TestReactor_DiscoveryResponse(t *testing.T) { slices.ContainsFunc(resp.Peers, func(addr *types.NetAddress) bool { for _, localP := range peers { - if localP.SocketAddr().Equals(*addr) { + if localP.NodeInfo().DialAddress().Equals(*addr) { return true } } @@ -317,7 +317,7 @@ func TestReactor_DiscoveryResponse(t *testing.T) { slices.ContainsFunc(resp.Peers, func(addr *types.NetAddress) bool { for _, localP := range peers { - if localP.SocketAddr().Equals(*addr) { + if localP.NodeInfo().DialAddress().Equals(*addr) { return true } } @@ -373,7 +373,7 @@ func TestReactor_DiscoveryResponse(t *testing.T) { peerAddrs := make([]*types.NetAddress, 0, len(peers)) for _, p := range peers { - peerAddrs = append(peerAddrs, p.SocketAddr()) + peerAddrs = append(peerAddrs, p.NodeInfo().DialAddress()) } // Prepare the message diff --git a/tm2/pkg/p2p/mock/peer.go b/tm2/pkg/p2p/mock/peer.go index e5a01952831..5be34121924 100644 --- a/tm2/pkg/p2p/mock/peer.go +++ b/tm2/pkg/p2p/mock/peer.go @@ -57,7 +57,7 @@ func GeneratePeers(t *testing.T, count int) []*Peer { }, NodeInfoFn: func() types.NodeInfo { return types.NodeInfo{ - PeerID: key.ID(), + NetAddress: addr, } }, SocketAddrFn: func() *types.NetAddress { diff --git a/tm2/pkg/p2p/peer.go b/tm2/pkg/p2p/peer.go index 135bf4b250c..dcca81ca097 100644 --- a/tm2/pkg/p2p/peer.go +++ b/tm2/pkg/p2p/peer.go @@ -160,7 +160,7 @@ func (p *peer) OnStop() { // ID returns the peer's ID - the hex encoded hash of its pubkey. func (p *peer) ID() types.ID { - return p.nodeInfo.PeerID + return p.nodeInfo.ID() } // NodeInfo returns a copy of the peer's NodeInfo. diff --git a/tm2/pkg/p2p/peer_test.go b/tm2/pkg/p2p/peer_test.go index a74ea9e96a4..75f5172ee66 100644 --- a/tm2/pkg/p2p/peer_test.go +++ b/tm2/pkg/p2p/peer_test.go @@ -243,7 +243,9 @@ func TestPeer_Properties(t *testing.T) { }, }, nodeInfo: types.NodeInfo{ - PeerID: id, + NetAddress: &types.NetAddress{ + ID: id, + }, }, connInfo: &ConnInfo{ Outbound: testCase.outbound, diff --git a/tm2/pkg/p2p/switch.go b/tm2/pkg/p2p/switch.go index 7d9e768dd4b..c96e429973e 100644 --- a/tm2/pkg/p2p/switch.go +++ b/tm2/pkg/p2p/switch.go @@ -5,6 +5,7 @@ import ( "context" "crypto/rand" "encoding/binary" + "errors" "fmt" "math" "sync" @@ -406,57 +407,76 @@ func (sw *MultiplexSwitch) runRedialLoop(ctx context.Context) { peersToDial = make([]*types.NetAddress, 0) ) + // Gather addresses of persistent peers that are missing or + // not already in the dial queue sw.persistentPeers.Range(func(key, value any) bool { var ( id = key.(types.ID) addr = value.(*types.NetAddress) ) - // Check if the peer is part of the peer set - // or is scheduled for dialing - if peers.Has(id) || sw.dialQueue.Has(addr) { - return true + if !peers.Has(id) && !sw.dialQueue.Has(addr) { + peersToDial = append(peersToDial, addr) } - peersToDial = append(peersToDial, addr) - return true }) if len(peersToDial) == 0 { - // No persistent peers are missing + // No persistent peers need dialing return } - // Calculate the dial items + // Prepare dial items with the appropriate backoff dialItems := make([]dial.Item, 0, len(peersToDial)) - for _, p := range peersToDial { - item := getBackoffItem(p.ID) + for _, addr := range peersToDial { + item := getBackoffItem(addr.ID) + if item == nil { - dialItem := dial.Item{ - Time: time.Now(), - Address: p, - } + // First attempt + now := time.Now() + + dialItems = append(dialItems, + dial.Item{ + Time: now, + Address: addr, + }, + ) - dialItems = append(dialItems, dialItem) - setBackoffItem(p.ID, &backoffItem{dialItem.Time, 0}) + setBackoffItem(addr.ID, &backoffItem{ + lastDialTime: now, + attempts: 0, + }) continue } - setBackoffItem(p.ID, &backoffItem{ - lastDialTime: time.Now().Add( + // Subsequent attempt: apply backoff + var ( + attempts = item.attempts + 1 + dialTime = time.Now().Add( calculateBackoff( item.attempts, time.Second, 10*time.Minute, ), - ), - attempts: item.attempts + 1, + ) + ) + + dialItems = append(dialItems, + dial.Item{ + Time: dialTime, + Address: addr, + }, + ) + + setBackoffItem(addr.ID, &backoffItem{ + lastDialTime: dialTime, + attempts: attempts, }) } - // Add the peers to the dial queue + // Add these items to the dial queue sw.dialItems(dialItems...) } @@ -622,50 +642,50 @@ func (sw *MultiplexSwitch) isPrivatePeer(id types.ID) bool { // and persisting them func (sw *MultiplexSwitch) runAcceptLoop(ctx context.Context) { for { - select { - case <-ctx.Done(): - sw.Logger.Debug("switch context close received") + p, err := sw.transport.Accept(ctx, sw.peerBehavior) - return + switch { + case err == nil: // ok + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + // Upper context as been canceled/timeout + sw.Logger.Debug("switch context close received") + return // exit + case errors.As(err, &errTransportClosed): + // Underlaying transport as been closed + sw.Logger.Warn("cannot accept connection on closed transport, exiting") + return // exit default: - p, err := sw.transport.Accept(ctx, sw.peerBehavior) - if err != nil { - sw.Logger.Error( - "error encountered during peer connection accept", - "err", err, - ) + // An error occurred during accept, report and continue + sw.Logger.Error("error encountered during peer connection accept", "err", err) + continue + } - continue - } + // Ignore connection if we already have enough peers. + if in := sw.Peers().NumInbound(); in >= sw.maxInboundPeers { + sw.Logger.Info( + "Ignoring inbound connection: already have enough inbound peers", + "address", p.SocketAddr(), + "have", in, + "max", sw.maxInboundPeers, + ) - // Ignore connection if we already have enough peers. - if in := sw.Peers().NumInbound(); in >= sw.maxInboundPeers { - sw.Logger.Info( - "Ignoring inbound connection: already have enough inbound peers", - "address", p.SocketAddr(), - "have", in, - "max", sw.maxInboundPeers, - ) + sw.transport.Remove(p) + continue + } - sw.transport.Remove(p) + // There are open peer slots, add peers + if err := sw.addPeer(p); err != nil { + sw.transport.Remove(p) - continue + if p.IsRunning() { + _ = p.Stop() } - // There are open peer slots, add peers - if err := sw.addPeer(p); err != nil { - sw.transport.Remove(p) - - if p.IsRunning() { - _ = p.Stop() - } - - sw.Logger.Info( - "Ignoring inbound connection: error while adding peer", - "err", err, - "id", p.ID(), - ) - } + sw.Logger.Info( + "Ignoring inbound connection: error while adding peer", + "err", err, + "id", p.ID(), + ) } } } diff --git a/tm2/pkg/p2p/switch_test.go b/tm2/pkg/p2p/switch_test.go index cf0a0c41bb5..e5f472cc28e 100644 --- a/tm2/pkg/p2p/switch_test.go +++ b/tm2/pkg/p2p/switch_test.go @@ -727,7 +727,7 @@ func TestMultiplexSwitch_DialPeers(t *testing.T) { // as the transport (node) p.NodeInfoFn = func() types.NodeInfo { return types.NodeInfo{ - PeerID: addr.ID, + NetAddress: &addr, } } @@ -890,3 +890,34 @@ func TestCalculateBackoff(t *testing.T) { } }) } + +func TestSwitchAcceptLoopTransportClosed(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var transportClosed bool + mockTransport := &mockTransport{ + acceptFn: func(context.Context, PeerBehavior) (PeerConn, error) { + transportClosed = true + return nil, errTransportClosed + }, + } + + sw := NewMultiplexSwitch(mockTransport) + + // Run the accept loop + done := make(chan struct{}) + go func() { + sw.runAcceptLoop(ctx) + close(done) // signal that accept loop as ended + }() + + select { + case <-time.After(time.Second * 2): + require.FailNow(t, "timeout while waiting for running loop to stop") + case <-done: + assert.True(t, transportClosed) + } +} diff --git a/tm2/pkg/p2p/transport.go b/tm2/pkg/p2p/transport.go index 150072ad5eb..3d64a48f437 100644 --- a/tm2/pkg/p2p/transport.go +++ b/tm2/pkg/p2p/transport.go @@ -2,6 +2,7 @@ package p2p import ( "context" + goerrors "errors" "fmt" "io" "log/slog" @@ -22,7 +23,6 @@ const defaultHandshakeTimeout = 3 * time.Second var ( errTransportClosed = errors.New("transport is closed") - errTransportInactive = errors.New("transport is inactive") errDuplicateConnection = errors.New("duplicate peer connection") errPeerIDNodeInfoMismatch = errors.New("connection ID does not match node info ID") errPeerIDDialMismatch = errors.New("connection ID does not match dialed ID") @@ -75,7 +75,10 @@ func NewMultiplexTransport( mConfig conn.MConnConfig, logger *slog.Logger, ) *MultiplexTransport { + ctx, cancel := context.WithCancel(context.Background()) return &MultiplexTransport{ + ctx: ctx, + cancelFn: cancel, peerCh: make(chan peerInfo, 1), mConfig: mConfig, nodeInfo: nodeInfo, @@ -92,12 +95,6 @@ func (mt *MultiplexTransport) NetAddress() types.NetAddress { // Accept waits for a verified inbound Peer to connect, and returns it [BLOCKING] func (mt *MultiplexTransport) Accept(ctx context.Context, behavior PeerBehavior) (PeerConn, error) { - // Sanity check, no need to wait - // on an inactive transport - if mt.listener == nil { - return nil, errTransportInactive - } - select { case <-ctx.Done(): return nil, ctx.Err() @@ -147,39 +144,31 @@ func (mt *MultiplexTransport) Close() error { } // Listen starts an active process of listening for incoming connections [NON-BLOCKING] -func (mt *MultiplexTransport) Listen(addr types.NetAddress) (rerr error) { +func (mt *MultiplexTransport) Listen(addr types.NetAddress) error { // Reserve a port, and start listening ln, err := net.Listen("tcp", addr.DialString()) if err != nil { return fmt.Errorf("unable to listen on address, %w", err) } - defer func() { - if rerr != nil { - ln.Close() - } - }() - if addr.Port == 0 { // net.Listen on port 0 means the kernel will auto-allocate a port // - find out which one has been given to us. tcpAddr, ok := ln.Addr().(*net.TCPAddr) if !ok { + ln.Close() return fmt.Errorf("error finding port (after listening on port 0): %w", err) } addr.Port = uint16(tcpAddr.Port) } - // Set up the context - mt.ctx, mt.cancelFn = context.WithCancel(context.Background()) - mt.netAddr = addr mt.listener = ln // Run the routine for accepting // incoming peer connections - go mt.runAcceptLoop() + go mt.runAcceptLoop(mt.ctx) return nil } @@ -189,60 +178,58 @@ func (mt *MultiplexTransport) Listen(addr types.NetAddress) (rerr error) { // 1. accepted by the transport // 2. filtered // 3. upgraded (handshaked + verified) -func (mt *MultiplexTransport) runAcceptLoop() { +func (mt *MultiplexTransport) runAcceptLoop(ctx context.Context) { var wg sync.WaitGroup - defer func() { wg.Wait() // Wait for all process routines - close(mt.peerCh) }() - for { - select { - case <-mt.ctx.Done(): - mt.logger.Debug("transport accept context closed") + ctx, cancel := context.WithCancel(ctx) + defer cancel() // cancel sub-connection process - return + for { + // Accept an incoming peer connection + c, err := mt.listener.Accept() + + switch { + case err == nil: // ok + case goerrors.Is(err, net.ErrClosed): + // Listener has been closed, this is not recoverable. + mt.logger.Debug("listener has been closed") + return // exit default: - // Accept an incoming peer connection - c, err := mt.listener.Accept() + // An error occurred during accept, report and continue + mt.logger.Warn("accept p2p connection error", "err", err) + continue + } + + // Process the new connection asynchronously + wg.Add(1) + + go func(c net.Conn) { + defer wg.Done() + + info, err := mt.processConn(c, "") if err != nil { mt.logger.Error( - "unable to accept p2p connection", + "unable to process p2p connection", "err", err, ) - continue - } - - // Process the new connection asynchronously - wg.Add(1) + // Close the connection + _ = c.Close() - go func(c net.Conn) { - defer wg.Done() - - info, err := mt.processConn(c, "") - if err != nil { - mt.logger.Error( - "unable to process p2p connection", - "err", err, - ) - - // Close the connection - _ = c.Close() - - return - } + return + } - select { - case mt.peerCh <- info: - case <-mt.ctx.Done(): - // Give up if the transport was closed. - _ = c.Close() - } - }(c) - } + select { + case mt.peerCh <- info: + case <-ctx.Done(): + // Give up if the transport was closed. + _ = c.Close() + } + }(c) } } diff --git a/tm2/pkg/p2p/transport_test.go b/tm2/pkg/p2p/transport_test.go index 3eb3264ec2b..840eb974e76 100644 --- a/tm2/pkg/p2p/transport_test.go +++ b/tm2/pkg/p2p/transport_test.go @@ -122,14 +122,12 @@ func TestMultiplexTransport_Accept(t *testing.T) { transport := NewMultiplexTransport(ni, nk, mCfg, logger) - p, err := transport.Accept(context.Background(), nil) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + p, err := transport.Accept(ctx, nil) assert.Nil(t, p) - assert.ErrorIs( - t, - err, - errTransportInactive, - ) + assert.ErrorIs(t, err, context.DeadlineExceeded) }) t.Run("transport closed", func(t *testing.T) { @@ -239,7 +237,7 @@ func TestMultiplexTransport_Accept(t *testing.T) { ni := types.NodeInfo{ Network: network, // common network - PeerID: id, + NetAddress: na, Version: "v1.0.0-rc.0", Moniker: fmt.Sprintf("node-%d", index), VersionSet: make(versionset.VersionSet, 0), // compatible version set @@ -319,7 +317,7 @@ func TestMultiplexTransport_Accept(t *testing.T) { ni := types.NodeInfo{ Network: chainID, - PeerID: id, + NetAddress: na, Version: "v1.0.0-rc.0", Moniker: fmt.Sprintf("node-%d", index), VersionSet: make(versionset.VersionSet, 0), // compatible version set @@ -391,7 +389,7 @@ func TestMultiplexTransport_Accept(t *testing.T) { ni := types.NodeInfo{ Network: network, // common network - PeerID: key.ID(), + NetAddress: na, Version: "v1.0.0-rc.0", Moniker: fmt.Sprintf("node-%d", index), VersionSet: make(versionset.VersionSet, 0), // compatible version set @@ -469,7 +467,7 @@ func TestMultiplexTransport_Accept(t *testing.T) { ni := types.NodeInfo{ Network: network, // common network - PeerID: key.ID(), + NetAddress: na, Version: "v1.0.0-rc.0", Moniker: fmt.Sprintf("node-%d", index), VersionSet: make(versionset.VersionSet, 0), // compatible version set diff --git a/tm2/pkg/p2p/types/node_info.go b/tm2/pkg/p2p/types/node_info.go index 8452cb43cb8..4080ff2d8aa 100644 --- a/tm2/pkg/p2p/types/node_info.go +++ b/tm2/pkg/p2p/types/node_info.go @@ -14,7 +14,6 @@ const ( ) var ( - ErrInvalidPeerID = errors.New("invalid peer ID") ErrInvalidVersion = errors.New("invalid node version") ErrInvalidMoniker = errors.New("invalid node moniker") ErrInvalidRPCAddress = errors.New("invalid node RPC address") @@ -30,8 +29,8 @@ type NodeInfo struct { // Set of protocol versions VersionSet versionset.VersionSet `json:"version_set"` - // Unique peer identifier - PeerID ID `json:"id"` + // The advertised net address of the peer + NetAddress *NetAddress `json:"net_address"` // Check compatibility. // Channels are HexBytes so easier to read as JSON @@ -54,12 +53,27 @@ type NodeInfoOther struct { // Validate checks the self-reported NodeInfo is safe. // It returns an error if there // are too many Channels, if there are any duplicate Channels, -// if the ListenAddr is malformed, or if the ListenAddr is a host name +// if the NetAddress is malformed, or if the NetAddress is a host name // that can not be resolved to some IP func (info NodeInfo) Validate() error { - // Validate the ID - if err := info.PeerID.Validate(); err != nil { - return fmt.Errorf("%w, %w", ErrInvalidPeerID, err) + // There are a few checks that need to be performed when validating + // the node info's net address: + // - the ID needs to be valid + // - the FORMAT of the net address needs to be valid + // + // The key nuance here is that the net address is not being validated + // for its "dialability", but whether it's of the correct format. + // + // Unspecified IPs are tolerated (ex. 0.0.0.0 or ::), + // because of legacy logic that assumes node info + // can have unspecified IPs (ex. no external address is set, use + // the listen address which is bound to 0.0.0.0). + // + // These types of IPs are caught during the + // real peer info sharing process, since they are undialable + _, err := NewNetAddressFromString(NetAddressString(info.NetAddress.ID, info.NetAddress.DialString())) + if err != nil { + return fmt.Errorf("invalid net address in node info, %w", err) } // Validate Version @@ -100,7 +114,12 @@ func (info NodeInfo) Validate() error { // ID returns the local node ID func (info NodeInfo) ID() ID { - return info.PeerID + return info.NetAddress.ID +} + +// DialAddress is the advertised peer dial address (share-able) +func (info NodeInfo) DialAddress() *NetAddress { + return info.NetAddress } // CompatibleWith checks if two NodeInfo are compatible with each other. diff --git a/tm2/pkg/p2p/types/node_info_test.go b/tm2/pkg/p2p/types/node_info_test.go index d03d77e608f..575d8ae5fbd 100644 --- a/tm2/pkg/p2p/types/node_info_test.go +++ b/tm2/pkg/p2p/types/node_info_test.go @@ -2,23 +2,43 @@ package types import ( "fmt" + "net" "testing" + "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/versionset" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNodeInfo_Validate(t *testing.T) { t.Parallel() + generateNetAddress := func() *NetAddress { + var ( + key = GenerateNodeKey() + address = "127.0.0.1:8080" + ) + + tcpAddr, err := net.ResolveTCPAddr("tcp", address) + require.NoError(t, err) + + addr, err := NewNetAddress(key.ID(), tcpAddr) + require.NoError(t, err) + + return addr + } + t.Run("invalid peer ID", func(t *testing.T) { t.Parallel() info := &NodeInfo{ - PeerID: "", // zero + NetAddress: &NetAddress{ + ID: "", // zero + }, } - assert.ErrorIs(t, info.Validate(), ErrInvalidPeerID) + assert.ErrorIs(t, info.Validate(), crypto.ErrZeroID) }) t.Run("invalid version", func(t *testing.T) { @@ -47,8 +67,8 @@ func TestNodeInfo_Validate(t *testing.T) { t.Parallel() info := &NodeInfo{ - PeerID: GenerateNodeKey().ID(), - Version: testCase.version, + NetAddress: generateNetAddress(), + Version: testCase.version, } assert.ErrorIs(t, info.Validate(), ErrInvalidVersion) @@ -86,8 +106,8 @@ func TestNodeInfo_Validate(t *testing.T) { t.Parallel() info := &NodeInfo{ - PeerID: GenerateNodeKey().ID(), - Moniker: testCase.moniker, + NetAddress: generateNetAddress(), + Moniker: testCase.moniker, } assert.ErrorIs(t, info.Validate(), ErrInvalidMoniker) @@ -121,8 +141,8 @@ func TestNodeInfo_Validate(t *testing.T) { t.Parallel() info := &NodeInfo{ - PeerID: GenerateNodeKey().ID(), - Moniker: "valid moniker", + NetAddress: generateNetAddress(), + Moniker: "valid moniker", Other: NodeInfoOther{ RPCAddress: testCase.rpcAddress, }, @@ -162,9 +182,9 @@ func TestNodeInfo_Validate(t *testing.T) { t.Parallel() info := &NodeInfo{ - PeerID: GenerateNodeKey().ID(), - Moniker: "valid moniker", - Channels: testCase.channels, + NetAddress: generateNetAddress(), + Moniker: "valid moniker", + Channels: testCase.channels, } assert.ErrorIs(t, info.Validate(), testCase.expectedErr) @@ -176,9 +196,9 @@ func TestNodeInfo_Validate(t *testing.T) { t.Parallel() info := &NodeInfo{ - PeerID: GenerateNodeKey().ID(), - Moniker: "valid moniker", - Channels: []byte{10, 20, 30}, + NetAddress: generateNetAddress(), + Moniker: "valid moniker", + Channels: []byte{10, 20, 30}, Other: NodeInfoOther{ RPCAddress: "0.0.0.0:26657", }, diff --git a/tm2/pkg/service/service.go b/tm2/pkg/service/service.go index 05f7a4f4ae6..c93eb06b298 100644 --- a/tm2/pkg/service/service.go +++ b/tm2/pkg/service/service.go @@ -159,7 +159,7 @@ func (bs *BaseService) OnStart() error { return nil } func (bs *BaseService) Stop() error { if atomic.CompareAndSwapUint32(&bs.stopped, 0, 1) { if atomic.LoadUint32(&bs.started) == 0 { - bs.Logger.Error(fmt.Sprintf("Not stopping %v -- have not been started yet", bs.name), "impl", bs.impl) + bs.Logger.Warn(fmt.Sprintf("Not stopping %v -- have not been started yet", bs.name), "impl", bs.impl) // revert flag atomic.StoreUint32(&bs.stopped, 0) return ErrNotStarted