diff --git a/CHANGELOG.md b/CHANGELOG.md index e50503f24..5b42029b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,6 +148,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) - In Go, a call is now matched to the package or variable it is actually made on. Before, a call through a package, like `klog.Infof("Log using Infof")` or `digest.Digest(dig)` inside `&distribution.Descriptor{Digest: …}`, could link to a project method of the same name when the name appeared again on the line, and in `klog.Error(err.Error())` the `err.Error()` call was taken for a call into `klog`. A parameter, receiver or local variable named like an import, like etcd's `jwt, err := newTokenProviderJWT(…)` or the `suite` receiver in a testify test suite, now counts as that variable, so calls on it link to the methods of the type it is declared as, and to nothing when that type comes from outside your project. Re-index Go projects after upgrading. - In Go, a call through a parameter or local variable named like a standard-library package, such as `context`, `log`, `trace`, `printer` or `token`, now links to the method it calls. Before, every call written `context.…` or `printer.…` was taken for a call into the standard library and linked to nothing: gin's `context.AbortWithError(…)` inside `func(context *Context)`, harbor's `context.IsAuthenticated()` after `context := NewSecurityContext(…)` and `log.Errorf(…)` after `log := log.G(ctx)`, etcd's `printer.DBHashKV(…)` and `trace.Step(…)`. The variable's declaration decides the method: the type it is declared as, or the package of the project function its value comes from. A variable holding a value from outside your project, like `scanner := bufio.NewScanner(r)` or `url, err := url.Parse(raw)`, still links to nothing, and one whose type can't be told links only to a method no other type in the project has, never to one picked because it matches the variable's name. Re-index Go projects after upgrading. +- In Go, a call of a method name that nearly every type has, like `err.Error()`, `id.String()`, `ctx.Err()` or `mu.Lock()`, now links to that method of the type its variable is declared as, and to nothing when that type is built into Go, like `error` or `string`, or comes from outside your project, like `context.Context`, `sync.Mutex` or `testing.TB`. Before, when the variable's type wasn't worked out, such a call was matched by a word its name shares with a type's, so kubernetes' `err.Error()` calls linked to an unrelated `notRegisteredErr` type and etcd's `leaderID.String()` to `MoveLeaderRequest`. A call on a variable whose type still can't be told now links only to a type the variable is named after, like `id` for an `ID`. Re-index Go projects after upgrading. - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. - In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading. - A React Router route that lazily loads a folder, like `lazy: () => import('@/pages/invocation_page')`, now links to the page the folder's `index.ts` passes on, whether it writes `export * from './invocation_page'`, `export { default } from './page'` or `export { Page as Component } from './page'`, even through another `index.ts`. A route module that names its page with `export { Page as Component }` links to it as well, and so does a page loaded with `lazy(() => import(…))` through such an `index.ts`. Before, these routes linked to nothing, so their pages had no route leading to them. Re-index React projects after upgrading. diff --git a/__tests__/go-std-method-receivers.test.ts b/__tests__/go-std-method-receivers.test.ts new file mode 100644 index 000000000..49cff7fb7 --- /dev/null +++ b/__tests__/go-std-method-receivers.test.ts @@ -0,0 +1,337 @@ +/** + * Go calls of a standard method name — `Error`, `String`, `Err`, `Lock`, + * `Header` — on a receiver nothing types. + * + * Every Go type may carry such a name, so the name alone says nothing of the + * receiver. A guess by a word the receiver shares with a type's name bound + * kubernetes' `err.Error()` to `notRegisteredErr::Error` and etcd's + * `ctx.Err()` to a `valCtx`. The receiver's declaration decides when it can be + * read — its declared type's own method, or nothing for a predeclared type + * or a type from outside the project — and otherwise only a receiver named + * after the owner type itself gets a guess. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-std-recv-')); + const files: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'types/id.go': `package types + +type ID uint64 + +func (i ID) String() string { return "" } + +type URLsMap map[string][]string + +func NewURLsMap() URLsMap { return URLsMap{} } + +func (m URLsMap) String() string { return "" } +`, + 'runtime/error.go': `package runtime + +type notRegisteredErr struct{} + +func (e *notRegisteredErr) Error() string { return "" } + +type ParseErr struct{} + +func (e *ParseErr) Error() string { return "" } +`, + 'etcdserver/request.go': `package etcdserver + +type MoveLeaderRequest struct{} + +func (m *MoveLeaderRequest) String() string { return "" } +`, + 'client/watch.go': `package client + +import "context" + +type valCtx struct{ context.Context } + +func (c *valCtx) Err() error { return nil } +`, + 'concurrency/mutex.go': `package concurrency + +type Mutex struct{} + +func (m *Mutex) Lock() error { return nil } + +func (m *Mutex) Unlock() error { return nil } +`, + 'chunks/iterator.go': `package chunks + +type Iterator interface { + Next() bool + Err() error +} +`, + 'index/postings.go': `package index + +type StringIter interface { + Next() bool + Err() error +} + +type symbolsIter struct{} + +func (s *symbolsIter) Next() bool { return false } + +func (s *symbolsIter) Err() error { return nil } +`, + 'featuregate/fgtesting/tb.go': `package fgtesting + +type TB interface { + Error(args ...any) +} +`, + 'kubelet/runtime.go': `package kubelet + +type Status struct{ Code int } + +type RuntimeStatus struct{} + +func (r *RuntimeStatus) String() string { return "" } +`, + 'httpx/recorder.go': `package httpx + +import "net/http" + +type ResponseRecorder struct{} + +func (r *ResponseRecorder) Header() http.Header { return nil } +`, + 'boot/srv.go': `package boot + +type Srv struct{} + +func (s *Srv) Start() {} +`, + 'promql/engine.go': `package promql + +type Query interface { + Exec() string + String() string +} + +func NewQuery(q string) Query { return nil } +`, + 'server/version.go': `package server + +type Version struct{} + +func ParseVersion(s string) (*Version, error) { return &Version{}, nil } + +func (v *Version) Major() string { return "" } + +func (v *Version) String() string { return "" } +`, + 'server/server.go': `package server + +import ( + "context" + "fmt" + "net/http/httptest" + "sync" + "testing" + "time" + + "example.com/app/boot" + "example.com/app/chunks" + "example.com/app/promql" + "example.com/app/types" +) + +type Server struct{} + +func (s *Server) String() string { return "" } + +func (s *Server) Describe() string { return s.String() } + +func doWork() error { return nil } + +func lookupLeader() fmt.Stringer { return nil } + +func currentStatus() fmt.Stringer { return nil } + +func newSrv() *boot.Srv { return nil } + +func errorText(err error) string { return err.Error() } + +func workText() string { + err := doWork() + return err.Error() +} + +func leaderText() string { + leaderID := lookupLeader() + return leaderID.String() +} + +func memberText(memberID types.ID) string { return memberID.String() } + +func idsText(ids []types.ID) string { + out := "" + for _, id := range ids { + out += id.String() + } + return out +} + +func ctxErr(ctx context.Context) error { return ctx.Err() } + +func timeoutErr() error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + return ctx.Err() +} + +func locked() { + var mu sync.Mutex + mu.Lock() +} + +func iterErr(iter chunks.Iterator) error { return iter.Err() } + +func report(tb testing.TB) { tb.Error("failed") } + +func statusText() string { + status := currentStatus() + return status.String() +} + +func headers() { + recorder := httptest.NewRecorder() + _ = recorder.Header() +} + +func urls() string { + urlsMap := types.NewURLsMap() + return urlsMap.String() +} + +func startSrv() { + srv := newSrv() + srv.Start() +} + +func runQueries() { + for _, tc := range []struct { + query string + }{{query: "up"}} { + func() { + var ( + query promql.Query + ) + query = promql.NewQuery(tc.query) + _ = query.Exec() + _ = query.String() + }() + } +} + +func versionText() string { + tests := []struct { + version string + }{{version: "1.0"}} + out := "" + for _, test := range tests { + version, _ := ParseVersion(test.version) + out += version.Major() + version.String() + } + return out +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}, 120_000); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** `file::qualifiedName resolvedBy@confidence` of every method the function `name` in server.go calls. */ +function calls(name: string): string[] { + const fn = cg.getNodesInFile('server/server.go').find((n) => n.name === name && n.kind !== 'method'); + expect(fn, name).toBeDefined(); + return cg + .getOutgoingEdges(fn!.id) + .filter((e) => e.kind === 'calls') + .map((e) => ({ e, n: cg.getNode(e.target)! })) + .filter(({ n }) => n.kind === 'method') + .map(({ e, n }) => `${n.filePath.replace(/\\/g, '/')}::${n.qualifiedName} ${String(e.metadata?.resolvedBy)}@${String(e.metadata?.confidence)}`) + .sort(); +} + +describe('Go standard method names on a receiver nothing types', () => { + it.each([ + // `func errorText(err error)`: the error interface's own method. + ['errorText', 'a receiver of a predeclared type'], + // `err := doWork()`: an `err` is no `notRegisteredErr`. + ['workText', 'a receiver sharing only a word with the owner type'], + // `leaderID := lookupLeader()`: no `MoveLeaderRequest`. + ['leaderText', 'a receiver sharing only a word with the owner type'], + ['ctxErr', 'a parameter of a type from outside the project'], + // `ctx, cancel := context.WithTimeout(…)` + ['timeoutErr', 'a value a package from outside the project hands out'], + // `var mu sync.Mutex`: no project `Mutex`. + ['locked', 'a variable of a type from outside the project'], + // `tb testing.TB`, beside a project interface `TB`. + ['report', 'a parameter of a type from outside the project'], + // `recorder := httptest.NewRecorder()` + ['headers', 'a value a package from outside the project hands out'], + // `status := currentStatus()`, beside a `Status` type and a `RuntimeStatus::String`. + ['statusText', 'a receiver named after a type that lacks the method'], + ])('links nothing from %s: %s', (fn) => { + expect(calls(fn)).toEqual([]); + }); + + it.each([ + ['memberText', 'types/id.go::ID::String instance-method@0.9'], + // Not `StringIter::Err` or `symbolsIter::Err`, by the word "iter". + ['iterErr', 'chunks/iterator.go::Iterator::Err instance-method@0.9'], + ])('links %s through the type its parameter is declared as', (fn, target) => { + expect(calls(fn)).toEqual([target]); + }); + + it.each([ + // `for _, id := range ids` + ['idsText', 'types/id.go::ID::String instance-method@0.65'], + // `urlsMap := types.NewURLsMap()` + ['urls', 'types/id.go::URLsMap::String instance-method@0.65'], + ])('still guesses for %s through a receiver named after the owner type', (fn, target) => { + expect(calls(fn)).toEqual([target]); + }); + + it.each([ + // `var ( query promql.Query )`, read past the table's `query string`; + // `Exec` is no standard name and is guessed as before. + ['runQueries', ['promql/engine.go::Query::Exec instance-method@0.8', 'promql/engine.go::Query::String instance-method@0.9']], + // `version, _ := ParseVersion(…)` says no type, and the table's + // `version string` is another declaration: nothing is ruled out. + ['versionText', ['server/version.go::Version::Major instance-method@0.8', 'server/version.go::Version::String instance-method@0.8']], + ])('reads %s by the declaration in scope, not a test table field of the name', (fn, targets) => { + expect(calls(fn)).toEqual(targets); + }); + + it("leaves typed receivers and other method names as they were", () => { + const describeFn = cg.getNodesInFile('server/server.go').find((n) => n.qualifiedName === 'Server::Describe'); + expect(describeFn).toBeDefined(); + const targets = cg.getOutgoingEdges(describeFn!.id).filter((e) => e.kind === 'calls').map((e) => cg.getNode(e.target)!.qualifiedName); + expect(targets).toEqual(['Server::String']); + expect(calls('startSrv').filter((c) => c.includes('Srv::Start'))).toHaveLength(1); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 6489cfda7..dc1679efa 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2281,9 +2281,44 @@ export function goTypePositionTarget(result: ResolvedRef, ref: UnresolvedRef, co function isGoExternalQualified(ref: UnresolvedRef, context: ResolutionContext): boolean { const { imported, typePackage } = goRefQualification(ref, context); const pkg = imported ?? typePackage; - if (!pkg) return false; - return !(pkg.source.startsWith('.') || pkg.source.includes('/internal/') || - context.getGoPackageDir?.(pkg.source, ref.filePath) != null); + return !!pkg && isGoOutsideImport(pkg, ref.filePath, context); +} + +/** + * Whether a Go import is of a package from outside the project's modules. A + * relative or `/internal/` path is the project's own, mapped or not: Go lets + * only its own module tree import an internal package. + */ +function isGoOutsideImport(imp: ImportMapping, filePath: string, context: ResolutionContext): boolean { + return !(imp.source.startsWith('.') || imp.source.includes('/internal/') || + context.getGoPackageDir?.(imp.source, filePath) != null); +} + +/** + * Whether a Go type as a declaration writes it — `sync.Mutex`, `*testing.T` + * — comes through an import of a package from outside the project. + */ +function isGoOutsideType(written: string | undefined, filePath: string, context: ResolutionContext): boolean { + const qualifier = written && /^[*&\s]*([A-Za-z_]\w*)\s*\./.exec(written)?.[1]; + return !!qualifier && isGoOutsidePackage(qualifier, filePath, context); +} + +/** Whether `name` is the file's import of a package from outside the project: `context` in `context.WithTimeout(…)`. */ +function isGoOutsidePackage(name: string, filePath: string, context: ResolutionContext): boolean { + const imp = context.getImportMappings(filePath, 'go').find((m) => m.localName === name); + return !!imp && isGoOutsideImport(imp, filePath, context); +} + +/** + * The named type a Go declaration writes, as `T` or `pkg.T`, its pointer and + * type arguments dropped: `types.ID` for `*types.ID`, `sets.Set` for + * `sets.Set[string]`. Null for a type literal — a slice, map, channel, + * function, struct or interface — or a variadic `...T`. + */ +function goNamedType(written: string): string | null { + const m = /^\*?\s*([A-Za-z_]\w*)(?:\s*\.\s*([A-Za-z_]\w*))?\s*(?:\[.*\])?$/.exec(written.trim()); + if (!m || GO_TYPE_KEYWORDS.has(m[1]!)) return null; + return m[2] ? `${m[1]}.${m[2]}` : m[1]!; } /** @@ -5389,9 +5424,13 @@ const DART_STD_METHODS: ReadonlySet = new Set([ /** * Whether a receiver is named after the owner of `method` — for a Dart * extension, after the type it is `on`: getx's `ext.endsWith(".avi")` on a - * String shares a word with `RxStringExt`, none with its `Rx`. + * String shares a word with `RxStringExt`, none with its `Rx`. A Go + * receiver must name the owner type itself, case and a trailing number + * aside (`id` an `ID`, `urlsMap` a `URLsMap`): a shared word is what every + * `err` has with an `…Err` type, every `ctx` with a `valCtx`. */ function receiverNamesOwner(receiver: string, method: Node, context: ResolutionContext): boolean { + if (method.language === 'go') return namesOwnerType(receiver, method); if (method.language === 'dart') { const cut = method.qualifiedName.lastIndexOf('::'); const owner = cut > 0 ? method.qualifiedName.slice(0, cut).split('::').pop()! : ''; @@ -14225,6 +14264,10 @@ export function matchMethodCall( // A simple `receiver.method` / `receiver:method` / `receiver$method` shape whose // receiver type we can try to infer from its local declaration. const inferableReceiver = dotMatch || luaColonMatch || rDollarMatch; + // A Go call of a standard method name — `String`, `Error`, `Len`, `Lock` — + // on a plain variable: a name nearly every type carries, so the call says + // nothing of the receiver's type. + const goStd = ref.language === 'go' && !!dotMatch && !objectOrClass!.includes('.') && isStdMethodName('go', methodName!); // A Go variable named like a standard-library package resolves by its // declaration alone (isGoProjectLocalCall let it past the package list). @@ -14307,6 +14350,30 @@ export function matchMethodCall( decl.raw = declared; } } + // A Go standard method name says nothing of its receiver, so the + // receiver's declaration in scope, which the scope reader finds, decides: + // etcd's parameter `id types.ID` makes `id.String()` `ID::String`, + // prometheus's `iter chunks.Iterator` makes `iter.Err()` that interface's + // rather than `StringIter::Err`, and a value a package from outside the + // project hands out (`ctx, cancel := context.WithTimeout(…)`) has no + // project method. It overrides the patterns above, which scan back for + // the name without block scope and can read another declaration of it: + // prometheus's `var ( query promql.Query )` came out as its test table's + // `query string`. Only a type read here rules a call out below. + let goDeclared: string | undefined; + if (goStd && decl.asserted === undefined) { + const local = goLocalDecl(objectOrClass!, ref, context); + if (local?.type) { + const named = goNamedType(local.type); + // A slice, map, channel, function or interface literal type has no + // project method. + if (!named) return null; + inferredType = named.split('.').pop()!; + decl.raw = goDeclared = named; + } else if (local?.from && isGoOutsidePackage(local.from, ref.filePath, context)) { + return null; + } + } if (!inferredType && MEMBER_TYPED_LANGUAGES.has(ref.language) && dotMatch) { inferredType = nmTimedT('mc-member', ref, () => inferMemberReceiverType(objectOrClass!, ref, context)); // A field of a built-in type (`string _name`, `int count`) has no project method. @@ -14367,6 +14434,12 @@ export function matchMethodCall( if (decl.asserted !== undefined) { return decl.asserted && resolveMethodOnType(decl.asserted.name, methodName!, ref, context, 0.9, 'instance-method', decl.asserted.dir); } + // A standard method name on a Go receiver declared as a type from a + // package outside the project (`var mu sync.Mutex`, `tb testing.TB`) is + // that type's own method, never a same-named project type's: etcd's + // `pendingMu.Lock()` went to its concurrency `Mutex`, kubernetes' + // `tb.Error(…)` to a feature-gate test helper's `TB`. + if (goDeclared !== undefined && isGoOutsideType(goDeclared, ref.filePath, context)) return null; // Java/Kotlin: when two classes share the simple name, the file's import // pins WHICH one (#314); Go: the package that declares the type (#2323); // TS/JS: the file declaring the class the file imports under that name. @@ -14434,6 +14507,15 @@ export function matchMethodCall( ) { return null; } + // A Go standard method name the declared type lacks is no other + // type's: in kubernetes' `func …(err error)`, `err.Error()` is the error + // interface's, not `notRegisteredErr::Error` by the shared word, and a + // struct's `Lock` comes from an embedded `sync.Mutex`. (Valid Go calls + // no other method on a predeclared type, so a predeclared type the + // patterns read for another name's declaration, like a test table's + // `version string` above kubernetes' `version, _ := parse(…)`, rules + // nothing out.) + if (goDeclared !== undefined) return null; // The receiver's declared type is one the project doesn't declare — // `List list`, `String s`, `val sb = StringBuilder()` — so the // method is that outside type's. gson's `list.add(…)` went to a project @@ -14686,7 +14768,11 @@ export function matchMethodCall( if (classNode.language !== ref.language) continue; const nodesInFile = context.getNodesInFile(classNode.filePath); - const methodNode = nodesInFile.find((n) => isClassStrategyMethod(n, classNode, methodName!)); + const methodNode = nodesInFile.find((n) => isClassStrategyMethod(n, classNode, methodName!) && + // A Go standard method name is the type's own, not one of another + // type its name is part of: kubernetes' `status.String()` is no + // `RuntimeStatus::String` beside a `Status` type. + (!goStd || n.qualifiedName === `${classNode.name}::${methodName}`)); if (methodNode) { return { @@ -17090,14 +17176,24 @@ function isLuaLibraryCall(receiver: string, method: string, ref: UnresolvedRef, * WebPushRequest, `executor1` → Executor, `decodedImage` → UIImage). */ function sharesReceiverWord(receiver: string, method: Node): boolean { + if (namesOwnerType(receiver, method)) return true; const cut = method.qualifiedName.lastIndexOf('::'); if (cut < 0) return false; - const ownerQn = method.qualifiedName.slice(0, cut); - const flat = (w: string) => w.replace(/[^A-Za-z0-9]/g, '').replace(/\d+$/, '').toLowerCase(); - if (flat(receiver.split('.').pop()!) === flat(ownerQn.split(/::|\./).pop()!)) return true; // Two-letter words are class prefixes (`SD`, `NS`, `UI`), not names. - const owner = new Set(splitCamelCase(ownerQn).map(flat).filter((w) => w.length > 2)); - return splitCamelCase(receiver).some((w) => owner.has(flat(w))); + const owner = new Set(splitCamelCase(method.qualifiedName.slice(0, cut)).map(flatWord).filter((w) => w.length > 2)); + return splitCamelCase(receiver).some((w) => owner.has(flatWord(w))); +} + +/** Whether a receiver's last segment is the name of `method`'s owner, case and a trailing number aside (`cbsecurity` a CBSecurity). */ +function namesOwnerType(receiver: string, method: Node): boolean { + const cut = method.qualifiedName.lastIndexOf('::'); + if (cut < 0) return false; + return flatWord(receiver.split('.').pop()!) === flatWord(method.qualifiedName.slice(0, cut).split(/::|\./).pop()!); +} + +/** A name's letters and digits, lowercased, a trailing number dropped: `executor1` → `executor`. */ +function flatWord(w: string): string { + return w.replace(/[^A-Za-z0-9]/g, '').replace(/\d+$/, '').toLowerCase(); } function splitCamelCase(str: string): string[] {