From 2a2e0c322cfa076d28a160a257b6f53d8fe1a4b2 Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Wed, 1 Apr 2026 08:56:50 +0800 Subject: [PATCH 1/3] docs: add classfile resource specification Define framework resource kinds, pack-document discovery, typed scope bindings, and static tool semantics for classfile resources. Specify active project groups and flat project lowering, including shared project types, fragment ordering, field initialization, and callback assembly. Clarify pack-document merging and DQL query fragments relative to implicit discovery roots. Fixes #2704 Updates #2719 Updates #2802 Signed-off-by: Aofei Sheng --- doc/classfile-resource-spec.md | 506 +++++++++++++++++++++++++++++++++ doc/classfile-spec.md | 288 ++++++++++++++----- 2 files changed, 718 insertions(+), 76 deletions(-) create mode 100644 doc/classfile-resource-spec.md diff --git a/doc/classfile-resource-spec.md b/doc/classfile-resource-spec.md new file mode 100644 index 000000000..6c47738ab --- /dev/null +++ b/doc/classfile-resource-spec.md @@ -0,0 +1,506 @@ +# The XGo classfile resource specification + +This document defines the syntax and semantics of resources in XGo classfile frameworks. + +A resource is a framework-defined named entity that is visible to static analysis. + +In particular, this document defines: +- how a framework declares resource kinds and type-level resource bindings +- how a framework discovers concrete resource instances through DQL over pack documents +- how a framework declares API-position scope bindings for scoped resource references +- how work classfiles may imply top-level resource identities +- how source-level resource references participate in standardized static semantics + +## Terms + +The following terms are used throughout this document: +- Resource kind: a framework-defined resource category such as `sprite`, `sound`, or `sprite.costume` +- Top-level kind: a resource kind with no direct parent kind +- Scoped kind: a resource kind whose instances exist within the scope of another resource kind +- Framework package: the package named by the first package path of one project group +- String-based type: an exported defined type or exported alias whose type has underlying type `string` +- Canonical resource reference type: a string-based type bound to one resource kind +- Handle-bearing type: an exported defined interface type or exported defined struct type whose values may carry one + top-level resource identity of one resource kind +- Local resource name: the name of one resource instance within its direct parent scope, or at top level if its resource + kind is top-level +- Scope chain: the ordered chain of ancestor resource identities from the outermost ancestor to the direct parent +- Scope-unknown reference: a scoped resource reference whose complete scope chain is not statically available +- Framework registration identity: the identity of one framework registration in the classfile registry +- Resource identity: the stable logical identity of a resource instance, independent from storage path, manifest path, + URI syntax, and runtime object identity +- Resource instance: one concrete resource available to static analysis in one classfile project under one framework + registration +- Resource-discovery comment: a framework source directive comment that declares one concrete resource discovery query + fragment for one canonical resource reference type +- Resource-name-discovery comment: a framework source directive comment that declares one local resource name discovery + query fragment for one canonical resource reference type +- Pack document: the pack document defined by the XGo classfile specification +- Discovery origin node: one node matched by a resource-discovery comment query fragment and associated with one + discovered resource instance +- Callable site: one framework function declaration, one framework method declaration, or one interface method spec + declared by one top-level handle-bearing interface type declaration +- API position: the receiver or one numbered parameter position of one callable site +- Resource-api-scope-binding comment: a framework source directive comment that declares one direct API-position scope + binding for one scoped canonical resource reference type parameter position +- Resource-bearing work file kind: a work file kind whose work classfiles each imply one top-level resource instance +- Resource set: the set of concrete resource instances available to one implementation for one classfile project and one + framework registration +- Resource reference: a source-level reference to a resource + +The logical identity of a resource instance is the tuple: +- framework registration identity +- resource kind +- optional scope chain +- local resource name + +In this specification, the resource kind in a resource identity is the instance's own kind. It does not encode any +concrete ancestor identities, even when the kind spelling uses dotted segments such as `sprite.costume.frame`. + +## Notation + +The syntax in this document is specified using EBNF. It uses the same EBNF conventions as the XGo classfile +specification and the Go specification. + +```ebnf +ResourceComment = "//xgo:class:resource" ResourceKind . +ResourceDiscoveryComment = "//xgo:class:resource-discovery" DQLQueryFragment . +ResourceNameDiscoveryComment = "//xgo:class:resource-name-discovery" DQLQueryFragment . +ResourceAPIScopeBindingComment = "//xgo:class:resource-api-scope-binding" ScopeBindingTarget ScopeBindingSource . + +ResourceKind = ResourceSegment { "." ResourceSegment } . +ResourceSegment = lower_letter { lower_letter | decimal_digit | "_" } . +ScopeBindingTarget = ParameterPosition . +ScopeBindingSource = "receiver" | ParameterPosition . +ParameterPosition = "param." decimal_digit { decimal_digit } . +lower_letter = "a" ... "z" . +``` + +The lexical production `decimal_digit` is as in the Go specification. The directive comment form is as in Go directive +comment conventions. + +`DQLQueryFragment` is the remaining text of one resource-discovery or resource-name-discovery comment line and must be +one DQL query fragment. It is evaluated relative to an implicit root supplied by the discovery execution context. + +`ResourceComment` attaches to one immediately following top-level type spec that declares exactly one exported type name +and no type parameters. + +`ParameterPosition` uses zero-based decimal indexing in ordinary parameter source order and must not use leading zeros +other than `0` itself. + +In one dotted `ResourceKind`, each `.` separates one nested resource-kind segment from its direct parent prefix. A +single-segment kind is top-level. A multi-segment kind is scoped. The direct parent kind of one multi-segment kind is +the prefix that remains after removing its final `.` segment. + +## Conformance + +An implementation conforms to this specification if it implements the syntax and semantics defined by this specification +and satisfies every rule stated using the terms "must" and "must not". + +Rules stated using "may" describe permitted behavior. Rules stated using "should" describe recommended behavior. + +Capabilities such as hover, completion, diagnostics, rename, and references are optional tool capabilities. If an +implementation provides one of those capabilities for standardized resource semantics, the capability must satisfy all +applicable "must" and "must not" rules in this specification. + +## Framework source metadata + +Framework source metadata declares which resource kinds exist, how source code refers to them, which canonical resource +reference types carry discovery query fragments and optional local-name discovery query fragments, and how framework +callable sites may provide explicit direct scope to scoped resource positions. + +### Resource comments + +A resource comment belongs to the immediately following top-level type spec that declares exactly one exported type name +and no type parameters in the framework package of the containing project group. + +A resource comment on any other declaration, on one grouped type declaration as a whole rather than on one contained +type spec, or in any other package, has no standardized meaning in this specification. + +A declaration must not bear more than one resource comment with standardized meaning. + +A resource kind is declared when a resource comment with standardized meaning names it. + +If the declaration bearing a resource comment is a string-based type: +- the comment declares the canonical resource reference type of the named resource kind +- the declaration is the canonical resource reference type declaration of that kind + +If the declaration bearing a resource comment is an exported defined interface type or exported defined struct type: +- the comment declares a handle-bearing type of the named resource kind +- the named resource kind must be top-level + +If the declaration bearing a resource comment is neither a string-based type nor an exported defined interface type nor +an exported defined struct type, the comment has no standardized meaning in this specification. + +Within one project group: +- the direct parent kind of each scoped kind must be declared in the same project group +- a resource kind must not have more than one canonical resource reference type +- a declaration that bears a resource comment with standardized meaning must belong to the framework package of the + containing project group + +A top-level resource kind may have zero, one, or more handle-bearing types. + +A handle-bearing type declaration does not by itself imply any resource instance and does not by itself define any +source-level resource-binding rule. + +Additional user-facing spellings may be expressed through ordinary Go aliases that reduce to the canonical resource +reference type of a kind and do not bear their own resource comments. + +### Resource-discovery comments + +A resource-discovery comment belongs to the immediately following declaration only if all of the following hold: +- the declaration is the canonical resource reference type declaration of one resource kind +- the declaration is in the framework package of the containing project group + +A resource-discovery comment on any other declaration, in any other package, or on the same declaration as another +resource-discovery comment, has no standardized meaning in this specification. + +Each canonical resource reference type declaration may bear at most one resource-discovery comment with standardized +meaning. + +A resource-discovery comment declares one DQL query fragment for discovering concrete resource instances of that +resource kind. + +### Resource-name-discovery comments + +A resource-name-discovery comment belongs to the immediately following declaration only if all of the following hold: +- the declaration is the canonical resource reference type declaration of one resource kind +- the declaration is in the framework package of the containing project group +- the same declaration bears one resource-discovery comment with standardized meaning + +A resource-name-discovery comment on any other declaration, in any other package, or on the same declaration as another +resource-name-discovery comment, has no standardized meaning in this specification. + +Each canonical resource reference type declaration may bear at most one resource-name-discovery comment with +standardized meaning. + +A resource-name-discovery comment declares one DQL query fragment for discovering local resource names of that resource +kind relative to discovery origin nodes. + +### Resource-api-scope-binding comments + +A resource-api-scope-binding comment belongs to the immediately following callable site only if all of the following +hold: +- the callable site is one top-level function declaration, one top-level method declaration, or one method spec declared + by one top-level handle-bearing interface type declaration +- the callable site is in the framework package of the containing project group + +A resource-api-scope-binding comment on any other callable site, in any other package, or on the same callable site as +another resource-api-scope-binding comment with the same target parameter position, has no standardized meaning in this +specification. + +Each callable site may bear zero or more resource-api-scope-binding comments with standardized meaning, but at most one +such comment may target one parameter position. + +A set of resource-api-scope-binding comments with standardized meaning on one callable site must induce an acyclic +directed relation over API positions. + +The meaning of one resource-api-scope-binding comment is independent of its source order relative to other such comments +on the same callable site. + +A resource-api-scope-binding comment declares one direct scope source for its target parameter position. + +## Concrete resource introduction + +Concrete resource introduction in this specification occurs either through resource-discovery comments and optional +resource-name-discovery comments over pack documents or through work classfile implication. + +### Discovery-based introduction + +Resource-discovery comments and optional resource-name-discovery comments introduce project-derived resource instances +over pack documents. + +#### Discovery execution + +For a top-level resource kind, the resource-discovery comment query fragment is evaluated relative to the root node of +the pack document, if any, derived from the active project group identified by the current framework registration +identity. + +For a scoped resource kind whose direct parent kind is ``, the resource-discovery comment query fragment is +evaluated relative to each discovery origin node of each discovered direct parent resource instance of kind +``. + +Relative evaluation means that the discovery origin node is used as the implicit DQL root for that evaluation. + +If a direct parent resource identity is available only from work-classfile implication and not from any discovery origin +node, that implied identity alone does not create a relative discovery root. + +An implementation must preserve at least one discovery origin node for each discovered resource instance. + +If one discovered resource identity is obtained from more than one discovery origin node, relative child discovery is +evaluated for each such origin node. Child identities obtained from those evaluations are merged by resource identity. + +If a canonical resource reference type declaration bears a resource-name-discovery comment, its query fragment is +evaluated relative to each discovery origin node produced by the resource-discovery comment on that declaration. + +Relative evaluation of a resource-name-discovery comment does not change the discovery origin node associated with the +discovered resource instance and does not create any relative discovery root for child discovery. + +#### Discovery result interpretation + +A successful resource-discovery comment query fragment match contributes one discovered resource instance candidate. + +For the rules below: +- a string scalar value is one pack-document string scalar value +- a node key name is the key by which one matched node appears as one member of its containing pack-document object, if + any +- a string member named `name` is one object member named `name` whose value is a string scalar value + +The candidate's local resource name is determined as follows: +- if a resource-name-discovery comment is present: + - if its relative evaluation for that candidate does not produce exactly one matched node, the candidate is invalid + for discovery and does not contribute a resource instance + - if that matched node denotes a string scalar value, that string value is the local resource name + - otherwise, if the matched node has a non-empty node key name, that key name is the local resource name + - otherwise, if the matched node has a string member named `name`, the value of that member is the local resource name + - otherwise, the candidate is invalid for discovery and does not contribute a resource instance +- otherwise: + - if the discovery origin node has a non-empty node key name, that key name is the local resource name + - otherwise, if the discovery origin node has a string member named `name`, the value of that member is the local + resource name + - otherwise, the candidate is invalid for discovery and does not contribute a resource instance + +For a top-level kind, the discovered identity is `(resource kind, local resource name)`. + +For a scoped kind, the discovered identity is `(resource kind, scope chain, local resource name)`, where the direct +parent identity is inherited from the current relative discovery execution context. + +### Work classfile implication + +If a handle-bearing type declaration bearing a resource comment is the registered work base class declaration of one or +more work file kinds, each such work file kind is resource-bearing. + +A resource-bearing work file kind implies one top-level resource instance for each work classfile of that kind in the +analyzed classfile project. + +The implied local resource name is the class file stem of the work classfile, before any class type naming normalization +or `-prefix=` application. + +The implied resource identity is independent from generated Go type naming, including `-prefix=` adjustments. + +This rule standardizes one classfile-native top-level resource identity. Framework-specific runtime lookup keys, +reflection binding keys, and generated Go identifiers remain framework-defined. + +This rule affects only the static resource model. Compilation and runtime behavior remain unchanged. + +Only work classfiles may imply resources through this rule. + +A project fragment in a flat project group is not a work classfile and does not imply a resource through this rule. + +A work file kind must not imply more than one top-level resource kind through this rule. + +If two or more work classfiles imply the same resource identity in one analyzed classfile project: +- the identities collide +- an implementation may report a duplicate-resource diagnostic +- the colliding inputs must not be treated as distinct resource instances + +Implied resources from project classfiles and scoped classfile-implied resources are outside the scope of this +specification. + +### Identity merging + +If more than one source contributes the same resource identity, including resource-discovery comments and work-classfile +implication: +- they refer to the same logical resource instance +- metadata may merge +- the contributing sources must not change the resource kind, scope chain, or local resource name of that identity + +An implementation may preserve one origin, many origins, or provenance in a different internal form, subject to the +relative-discovery requirements above. + +### Example + +```go +// SpriteName identifies a sprite by name. +// +//xgo:class:resource sprite +//xgo:class:resource-discovery sprites.* +type SpriteName = string + +// SpriteCostumeName identifies a sprite costume by name. +// +//xgo:class:resource sprite.costume +//xgo:class:resource-discovery costumes.* +type SpriteCostumeName = string + +// WidgetName identifies a widget by name. +// +//xgo:class:resource widget +//xgo:class:resource-discovery widgets.* +//xgo:class:resource-name-discovery id +type WidgetName = string + +// SpriteImpl is a handle-bearing type of the sprite resource kind. +// +//xgo:class:resource sprite +type SpriteImpl struct{} +``` + +If one pack document contains `sprites.Hero`, the `sprite` query fragment may discover `sprite(Hero)` and preserve +`sprites.Hero` as one discovery origin node of that resource. The `sprite.costume` query fragment is then evaluated +relative to that origin node, so it may discover costumes of `Hero` without embedding `Hero` into the query text. + +If one `widget` origin node stores its local resource name in a child member `id` rather than in its node key or a child +member `name`, the `resource-name-discovery` query fragment `id` is evaluated relative to that origin node and yields +the local resource name. + +If the containing project group declares `class .spx SpriteImpl`, the resource comment on `SpriteImpl` makes `.spx` +resource-bearing, and each `.spx` work classfile implies one top-level `sprite` resource instance whose local resource +name is the class file stem. + +## Static semantics + +### Resource references + +A source position participates in standardized resource semantics only if ordinary Go typing determines one canonical +resource reference type declaration of one resource kind for that position. + +For this purpose, a canonical resource reference type declaration is determined for a source expression in one of the +following ways: +- the expression's static type is that canonical resource reference type declaration, or can be reduced to it by + following Go alias declarations only +- the surrounding typed position requires that canonical resource reference type, and the expression is assignable to it + under ordinary Go typing rules + +A distinct defined type does not participate in standardized resource semantics solely because both it and a canonical +resource reference type have underlying type `string`. + +A Go alias declaration denotes the same resource kind as that canonical resource reference type if its denoted type can +be reduced, recursively through Go alias declarations only, to the same canonical resource reference type declaration. + +An expression is canonically resource-typed if one canonical resource reference type declaration is determined for it in +that way. + +For one canonically resource-typed expression: +- if the expression is a string literal or a statically evaluable string constant, it is a resource reference candidate +- if the expression cannot be statically evaluated, it remains resource-typed but is not a resolvable resource reference +- for a top-level kind, the lookup key is `(resource kind, local resource name)` +- for a scoped kind, the lookup key is `(resource kind, scope chain, local resource name)` +- if a scoped kind does not have a statically available complete scope chain, the reference is one `scope-unknown` + reference + +### API-position scope bindings + +In this subsection, the callable site is the function declaration, method declaration, or interface method spec to which +one resource-api-scope-binding comment with standardized meaning belongs. + +One resource-api-scope-binding comment has standardized meaning only if all of the following hold: +- the target parameter position exists on the callable site +- the target parameter position is not the variadic parameter position of the callable site +- the target parameter type determines one canonical resource reference type declaration of one scoped kind +- the source API position exists on the callable site +- if the source API position is one parameter position, it is not the variadic parameter position of the callable site +- the source API position determines either: + - one canonical resource reference type declaration of the direct parent kind of the target kind, or + - one handle-bearing type of that direct parent kind + +At one call that resolves to that callable site, the source API position is interpreted as follows: +- if the source API position is `receiver`, the source expression is the call's receiver expression, whether explicit or + implicit +- if the source API position is one parameter position, the source expression is the corresponding argument expression + +One resource-api-scope-binding comment contributes one explicit direct parent identity to its target argument position +only if the source expression yields one exact resource identity of the target kind's direct parent kind under the +resource semantics otherwise available at that source position. + +For this purpose, a source position may itself use explicit scope contributed by other resource-api-scope-binding +comments on the same callable site, subject to the acyclicity rule above. + +If the target kind is multiply scoped, a resource-api-scope-binding comment contributes at most the direct parent +identity. Any additional ancestor levels come from that direct parent identity's own scope chain, if available. + +If a target position receives one explicit direct parent identity from a resource-api-scope-binding comment, that +identity is explicit scope for that target position. + +Example: + +```go +//xgo:class:resource-api-scope-binding param.0 receiver +func (p *SpriteImpl) SetCostume__0(costume SpriteCostumeName) + +//xgo:class:resource-api-scope-binding param.0 receiver +//xgo:class:resource-api-scope-binding param.1 param.0 +func (p *SpriteImpl) SetCostumeAndFrame(costume SpriteCostumeName, frame SpriteCostumeFrameName) +``` + +If one call to `SetCostume__0` yields one exact `sprite` identity at its receiver position, the bound +`SpriteCostumeName` argument position may use that identity as explicit scope. + +If one call to `SetCostumeAndFrame` yields one exact `sprite` identity at its receiver position, `param.0` may use that +identity as explicit scope. If `param.0` then yields one exact `sprite.costume` identity, the bound +`SpriteCostumeFrameName` argument position may use that identity as explicit scope. + +### Scoped owner inference + +If all of the following hold: +- the current source position is inside a work classfile +- the registered work base class declaration of that work file kind bears a resource comment for one top-level kind + `` +- the referenced scoped kind has direct parent kind `` +- no explicit scope is otherwise statically available + +then the statically available direct parent identity is the implied top-level resource of the containing work classfile. + +Example: +- if the declaration of `SpriteImpl` bears `//xgo:class:resource sprite` +- and the containing work classfile is registered by `class .spx SpriteImpl` +- and `SpriteCostumeName` bears `//xgo:class:resource sprite.costume` +- then a scoped `SpriteCostumeName` reference inside `Hero.spx` may use scope `(sprite, Hero)` + +If a resource-api-scope-binding comment contributes explicit scope to one target position, that explicit scope takes +precedence over the narrow rule above for that target position. + +Receiver-bound owner inference, project auto-binding owner inference, and other framework-specific context rules remain +framework-specific. + +If the narrow rule above does not apply, the scoped reference remains `scope-unknown`. + +The narrow rule above contributes at most one statically available parent identity. It does not by itself infer any +additional ancestor levels for multiply scoped kinds. + +If a framework uses plain `string` parameters or resource-like values that do not reduce to canonical resource reference +types, those positions are outside the standardized resource semantics of this specification. + +### Diagnostics + +A conforming implementation: +- may emit a "resource not found" diagnostic only when its framework-specific discovery semantics are exact for the + analyzed project state +- must not emit a "resource not found" diagnostic for dynamic expressions +- must not emit a "resource not found" diagnostic for `scope-unknown` references +- may emit a diagnostic for an empty resource name + +## Tool semantics + +### Hover + +When a source position resolves to a resource reference whose identity is present in the implementation's resource set, +an implementation that provides hover must return information about the corresponding resource instance. + +### Completion + +When the target type at an input position resolves to a resource kind, an implementation that provides completion may +base resource completion candidates on the implementation's resource set. + +For scoped kinds: +- if the scope chain is known, completion must be filtered to that scope chain +- if the scope chain is unknown, an implementation may suppress completion or provide degraded completion annotated with + scope information + +### Rename and references + +If an implementation supports rename or reference lookup for resources, resource identity must be based on +`(framework registration identity, resource kind, optional scope chain, local resource name)` rather than raw string +text. + +## Excluded semantics + +This specification intentionally does not standardize: +- framework-specific API-position scope rules beyond explicit resource-api-scope-binding comments +- API-position kind binding rules for positions that do not already depend on canonical resource reference types +- implied resources from project classfiles +- scoped classfile-implied resources beyond the work-classfile implication rule defined by this specification +- how runtime parameters such as `run(...)` are mapped back into static analysis +- the concrete encoding of preview URIs or editor-specific payloads +- how failed DQL query fragment evaluations affect discovery completeness or diagnostics +- one standardized completeness or exactness model for resource discovery diff --git a/doc/classfile-spec.md b/doc/classfile-spec.md index c8a26b7aa..02bedbcd8 100644 --- a/doc/classfile-spec.md +++ b/doc/classfile-spec.md @@ -2,7 +2,7 @@ This document defines the syntax and semantics of XGo classfiles. -A classfile is a source file that is compiled as a generated class type plus a set of generated methods and helper glue. +A classfile is a source file compiled through classfile-specific type, method, and entrypoint lowering. There are two kinds of classfiles: - A normal classfile, which is an otherwise unregistered `.gox` file @@ -13,13 +13,27 @@ There are two kinds of classfiles: The following terms are used throughout this document: - Class extension: the normalized classfile suffix used for framework lookup, e.g., `_app.gox` and `.gsh` - Class file stem: the filename without the class extension -- Class type: the generated named type for a classfile -- Field declaration block: the unique top-level `var` declaration that is interpreted as class fields rather than - package variables -- Shadow entry: the synthetic function created from top-level statements +- Classfile project: the source tree rooted at the analyzed package directory and interpreted by the classfile mechanism +- Framework registration: one project group loaded into the classfile registry +- Active project group: a framework registration under which at least one source file in the analyzed classfile project + is recognized as a framework classfile +- Project root directory: the root directory of one classfile project +- Pack root: the directory named by one `pack` directive and resolved relative to one project root directory +- Pack document: the logical merged configuration object derived from one pack root and its declared index filename +- Project file kind: a framework file kind declared by one `project` directive and used for project classfiles and, in a + flat project group, project fragments +- Work file kind: a framework file kind declared by one `class` directive and used for work classfiles +- Flat project group: a project group declared with `project -flat` - Project classfile: the framework file that represents the project-level class - Work classfile: a framework file that represents a non-project class within the same framework +- Project fragment classfile: a framework classfile in a flat project group that is not the project classfile and that + contributes to the shared project class type +- Class type: a generated named type for one classfile or one flat project group - Base class: an embedded framework type declared by classfile metadata +- Work prototype type: an exported type declared by classfile metadata and associated with one work file kind +- Field declaration block: a top-level `var` declaration that is interpreted as class fields rather than package + variables +- Shadow entry: the synthetic function created from top-level statements ## File classification @@ -49,24 +63,31 @@ If the second rule applies, the file is a normal classfile. If the first rule applies, the file is a framework classfile. -### Project and work classification +### Project, work, and fragment classification Each recognized class extension belongs to exactly one framework registration. -A recognized file is a project classfile if the registration marks it as a project file for the pair -`(class extension, filename)`. Otherwise it is a work classfile. +A recognized file in a non-flat project group is a project classfile if the registration marks it as a project file for +the pair `(class extension, filename)`. Otherwise it is a work classfile. -A recognized file is classified as follows: +A recognized file in a non-flat project group is classified as follows: - If a project extension and a work extension are different, every file whose normalized class extension equals the project extension is a project file - If a project extension and a work extension are the same, only the filename `main` plus that extension is a project file and all other files with the same extension are work files +In a flat project group: +- The filename `main` plus the project extension is the project classfile +- Every other recognized file with the project extension is a project fragment classfile +- No file is a work classfile + Examples: - Under metadata where the project extension and work extension are both `_case.gox`, `main_case.gox` is a project file and `foo_case.gox` is a work file - Under metadata where `_app.gox` is the project extension and `_cmd.gox` is the work extension, `demo_app.gox` is a project file and `list_cmd.gox` is a work file +- Under metadata `project -flat .spx Game example.com/framework`, `main.spx` is the project file and `hero.spx` is a + project fragment ## Source form @@ -157,9 +178,15 @@ Within the field declaration block: ### Type naming -For every classfile, the compiler generates one named class type. +The compiler generates: +- One named class type for each normal classfile +- One named class type for each project or work classfile in a non-flat project group +- One shared named project class type for each active flat project group -The initial class type name is derived from the class file stem. +A project fragment does not generate a distinct class type. + +The initial class type name is derived from the class file stem of the corresponding classfile. For a flat project +group, it is derived from the project classfile. The class file stem is normalized as follows: - `:` is removed @@ -172,9 +199,9 @@ Examples: - `get_p_#id_app.gox` has class file stem `get_p_#id` and normalized type name `get_p_id` Framework metadata may further transform the type name: -- A project file whose class file stem is `main`, or a framework that has no explicit project file, uses the project - base-class name as its default class type name. A leading `*` on the base-class name is removed -- A non-empty work-class `-prefix=` is prepended to the normalized work-file stem +- A project classfile whose class file stem is `main`, or a framework that has no explicit project classfile, uses the + name of the base type named by the project base class as its default class type name +- A non-empty `-prefix=` on a work file kind is prepended to the normalized class file stem of a work classfile - If neither of the previous rules applies and the class type name would otherwise equal one of the reserved names `init`, `main`, `go`, `goto`, `type`, `var`, `import`, `package`, `interface`, `struct`, `const`, `func`, `map`, `chan`, `for`, `if`, `else`, `switch`, `case`, `select`, `defer`, `range`, `return`, `break`, `continue`, @@ -188,19 +215,26 @@ The underlying type of every class type is a struct type. For a normal classfile, the struct fields are the user fields declared in the field declaration block, in source order. -For a framework classfile, framework-added fields precede user fields. +For a class type in a non-flat project group, framework-added fields precede the user fields declared in that +classfile's field declaration block. + +For a flat project group, framework-added fields precede all user fields contributed by its project classfile and +project fragments. The project classfile contributes first. Project fragments contribute in lexicographic source-file +path order. Fields within each field declaration block retain source order. ### Framework-added fields Framework-added fields are inserted in the following order: - For a project classfile, the embedded project base class -- For a project classfile, each embedded work-class field requested by the `-embed` flag, in work-class declaration +- For a project classfile, each embedded work class field requested by the `-embed` flag, in work file kind declaration order and lexicographic source-file path order - For a work classfile, the embedded work base class - For a work classfile, an embedded pointer to the project class type, if a project class type exists and its field name does not conflict with an already-present field name -User fields from the field declaration block are appended after all framework-added fields. +The embedded project base class is the only framework-added field in a flat project class type. + +User fields are appended after all framework-added fields in the order defined above. It is an error for two generated fields of the same class type to have the same field name. @@ -208,8 +242,8 @@ It is an error for two generated fields of the same class type to have the same ### Implicit receiver rewriting -In a classfile, every top-level function declaration without an explicit receiver is rewritten as a method on the -generated class type. +In a classfile, every top-level function declaration without an explicit receiver is rewritten as a method on the class +type associated with that classfile. For a project fragment, the associated class type is the shared project class type. The injected receiver is: - Named `this` @@ -235,8 +269,8 @@ The classfile parser also accepts the following classfile-only `FuncDecl` form: StaticMethodDecl = "func" "." identifier Signature [ FunctionBody ] . ``` -This form declares a static method associated with the generated class type. Its further lowering follows the ordinary -XGo static-method machinery. +This form declares a static method on the class type associated with that classfile. Its further lowering follows the +ordinary XGo static-method machinery. ### Shadow entry @@ -245,23 +279,28 @@ Top-level statements are not compiled as package-level statements. Instead, they are wrapped into a synthetic function with an initially empty parameter list, called the shadow entry. After classfile lowering, the shadow entry is renamed as follows: - `MainEntry` for a project classfile -- `Main` for any other classfile +- `Main` for a work or normal classfile +- an implementation-defined unexported name for a project fragment + +If a project or work classfile has no explicit top-level statement sequence, the compiler still synthesizes an empty +shadow entry with the same name. This ensures that those framework entry methods always exist. -If a framework classfile has no explicit top-level statement sequence, the compiler still synthesizes an empty shadow -entry with the same name. This ensures that framework entry methods always exist. +A project fragment without top-level statements has no shadow entry. For a normal classfile, no empty shadow entry is synthesized. A normal classfile without top-level statements therefore has no synthetic `Main` method. ### Base-entry forwarding -If a synthetic `Main` or `MainEntry` method is created for a framework classfile, and the embedded base class declares a -method with the same name, the synthetic method adopts that base method's parameter list and result list, with the -classfile receiver `*T` replacing the base receiver. +If a synthetic `Main` or `MainEntry` method is created for a project or work classfile, and the embedded base class +declares a method with the same name, the synthetic method adopts that base method's parameter list and result list, +with the classfile receiver `*T` replacing the base receiver. The generated body forwards the incoming arguments to the embedded base method before executing any user-written top-level statements. +A project fragment shadow entry always has no parameters and no results. It does not forward to a base method. + ### Execution order inside a synthetic entry method If a synthetic `Main` or `MainEntry` method is generated, its body executes in the following order: @@ -271,6 +310,9 @@ If a synthetic `Main` or `MainEntry` method is generated, its body executes in t This order is fixed. +A project fragment shadow entry executes only its user-written top-level statements and does not call `XGo_Init`. Any +automatic initialization of the shared project class type occurs through its synthetic `MainEntry` method. + If the adopted method signature has result parameters, the resulting body is checked under ordinary Go control-flow rules for that signature after lowering. @@ -278,15 +320,18 @@ rules for that signature after lowering. ### `XGo_Init` generation -If at least one field in the field declaration block has an initializer, the compiler generates a method: +If at least one user field of a class type has an initializer, the compiler generates a method: ```xgo func (this *T) XGo_Init() *T ``` -The generated method assigns all field initializers in field-declaration order and then returns `this`. +The generated method assigns all field initializers in generated field order and then returns `this`. + +For a flat project class type, this includes initializers contributed by the project classfile and all project +fragments. -If the field declaration block contains no initializers, no `XGo_Init` method is generated. +If the class type has no user field initializers, no `XGo_Init` method is generated. ### What `XGo_Init` does not do @@ -346,7 +391,7 @@ project _test.gox App github.com/goplus/xgo/test testing class _test.gox Case ``` -A built-in registration is active without any module declaration. +A built-in registration is loaded into the classfile registry without any module declaration. Built-in registrations participate in file classification, lowering, and package assembly exactly as if they were loaded from module metadata. @@ -356,21 +401,33 @@ from module metadata. The classfile loader recognizes the following module directives: ```ebnf -ProjectDirective = "project" [ ProjectExt ExportedName ] PackagePath { PackagePath } . -ClassDirective = "class" { ClassDirectiveFlag } WorkExt ExportedName [ ExportedName ] . -ImportDirective = "import" [ ImportName ] PackagePath . -ClassDirectiveFlag = "-embed" | "-prefix=" string_without_space . +ProjectDirective = "project" [ "-flat" ] [ ProjectExt ExportedName ] PackagePath { PackagePath } . +ClassDirective = "class" { ClassDirectiveFlag } WorkExt ExportedName [ ExportedName ] . +ImportDirective = "import" [ ImportName ] PackagePath . +PackDirective = "pack" RelativeDirectoryPath PackIndexFile . +ClassDirectiveFlag = "-embed" | "-prefix=" string_without_space . +RelativeDirectoryPath = string_without_space . +PackIndexFile = string_without_space . ``` -Every `class` or `import` directive belongs to the most recent preceding `project` directive. +`RelativeDirectoryPath` is one relative directory path token with no spaces. -A project group consists of one `project` directive together with all `class` and `import` directives that belong to it. +`PackIndexFile` is one plain file name token with no spaces. -The first package path of a `project` directive is the framework package used to resolve any base-class symbols named by -that project group. +Every `class`, `import`, or `pack` directive belongs to the most recent preceding `project` directive. -Any additional package paths participate in implicit framework-package export lookup but are not searched for -base-class symbols. +A project group consists of one `project` directive together with all `class`, `import`, and `pack` directives that +belong to it. + +One framework registration is active relative to one analyzed classfile project if at least one source file in that +project is recognized under the registration. An inactive registration does not cause project class synthesis, package +entrypoint synthesis, or pack-document derivation. + +The first package path of a `project` directive is the framework package used to resolve the project base class, each +work base class, and each work prototype type named by that project group. + +Any additional package paths participate in implicit framework-package export lookup but are not searched for those +symbols. ### Extension forms and normalization @@ -382,10 +439,9 @@ Both forms are part of the classfile mechanism. Neither form is a compatibility For newly defined framework registrations, `_[class].gox` is the recommended form. -This recommendation does not constrain the built-in registrations defined above. +The built-in registrations defined above remain valid as written. -This specification defines file classification and compilation semantics for both forms. It does not require auxiliary -tools to recognize arbitrary non-`.gox` class extensions automatically. +Auxiliary tool behavior for arbitrary non-`.gox` class extensions is implementation-defined. The textual extension token accepted by `project` and `class` directives may be written with a leading `*` and, for projects, may also be written with a leading `main`. @@ -403,18 +459,27 @@ For each `project` directive: - If `ProjectExt` and `ExportedName` are present, the directive defines a project file kind and names the project base class - If `ProjectExt` and `ExportedName` are omitted, the directive defines no project file kind or project base class and - still defines the package lookup set and the project group to which subsequent `class` and `import` directives belong + still defines the package lookup set and the project group to which subsequent `class`, `import`, and `pack` + directives belong - When a project base class is named, the first package path is the package from which that symbol is resolved - A project base-class name may be written with a leading `*`, in which case the generated project class embeds a pointer to the named base type rather than the base type itself +The `-flat` flag requires `ProjectExt` and `ExportedName` and declares the containing project group to be flat. The +project extension of a flat project group must not be `.gox`. + +A flat project group has one project file kind, has no work file kinds, and must not contain a `class` directive. + For each `class` directive: -- The exported symbol names the work base class +- The exported symbol names the work base class and is resolved from the framework package of the containing project + group - The optional final exported symbol is the work prototype type -- If a project group declares more than one work class kind, every work class in that group must declare a prototype - type +- If a project group declares more than one work file kind, every work file kind in that group must declare a work + prototype type +- When a work prototype type is named, the work prototype type symbol is resolved from the framework package of the + containing project group +- `-embed` causes the project class type to embed a field for each generated work class instance of that work file kind - `-prefix=` prepends the given string to every generated work class type name -- `-embed` causes the project class type to embed a field for each generated work class instance of that work kind For each `import` directive: - The imported package becomes available to classfiles as an auto-imported package name @@ -422,7 +487,16 @@ For each `import` directive: - If multiple `import` directives in the same project group resolve to the same auto-import name, the last directive wins -## Project and work-class assembly +For each `pack` directive: +- The project group may declare at most one `pack` directive +- The directory path is resolved relative to the project root directory of the analyzed classfile project +- The directory path must not contain any `..` path component +- The index filename determines the root configuration filename of the pack root and the child configuration filename + expected at each descendant directory level +- The index filename must not contain `/` or `\` +- The index filename must end in one of `.json`, `.yml`, or `.yaml` + +## Framework class assembly A test framework registration is a framework registration whose project extension has the suffix `test.gox`. @@ -430,39 +504,65 @@ All other framework registrations are non-test framework registrations. ### Default project synthesis -For each framework registration, a package may contain at most one explicit project classfile. +For each active non-flat project group, a package may contain at most one explicit project classfile. It is an error for a package to contain more than one explicit project classfile for the same framework registration. -If a framework registration provides a project base class but the package contains no explicit project file for that -framework, the compiler still synthesizes a default project class type. +If an active non-flat project group provides a project base class but the package contains no explicit project +classfile, the compiler still synthesizes a default project class type. The synthesized project class has no source file of its own. Its type name is derived by the project type-naming rules described earlier. -### Work-instance assembly for project `Main` +An active flat project group must contain exactly one project classfile. A flat project group never receives a +synthesized default project class. + +### Work instance assembly for project `Main` -For every non-test framework registration that provides a project base class, the compiler generates a project method -named `Main` on the project class type. The project base class is therefore required to provide a method named `Main`. -The generated project method constructs work-class instances and forwards them to the embedded project base-class method -`Main`. +For every active non-test, non-flat project group that provides a project base class, the compiler generates a project +method named `Main` on the project class type. The project base class is therefore required to provide a method named +`Main`. The generated project method constructs work class instances and forwards them to the embedded project +base-class method `Main`. The grouping rule is: -- If the framework has exactly one work class kind and that `Main` parameter is variadic, all work files of that kind - are passed as variadic arguments -- Otherwise, work files are grouped by their declared prototype type and passed as slices in `Main` parameter order +- If the framework has exactly one work file kind and that `Main` parameter is variadic, all work files of that kind are + passed as variadic arguments +- Otherwise, work files are grouped by their declared work prototype type and passed as slices in `Main` parameter order + +The project `Main` method constructs one fresh work class instance for each work file in the package. When `-embed` is +present on the corresponding `class` directive, the freshly created work instance is also assigned into the +corresponding embedded field on the project instance before the project `Main` call. + +### Flat project assembly for project `Main` -The project `Main` method constructs one fresh work-class instance for each work file in the package. When `-embed` is -present on a work class declaration, the freshly created work instance is also assigned into the corresponding embedded -field on the project instance before the project `Main` call. +For every active non-test flat project group, the compiler generates a project method named `Main` on the shared project +class type. + +The project base class must provide a method named `Main` whose parameter list contains exactly one non-variadic +parameter of type `func()` and whose result list is empty. + +If at least one project fragment exists, the compiler synthesizes one work callback method: + +```xgo +func (this *T) _xgo_WorkMain() +``` + +The callback invokes the shadow entry of each fragment that has top-level statements in lexicographic source-file path +order. The generated project `Main` passes the bound method value `this._xgo_WorkMain` to the embedded project +base-class method `Main`. + +If no project fragment exists, no `_xgo_WorkMain` method is generated and the generated project `Main` passes `nil`. + +All fragment shadow entries and `_xgo_WorkMain` use the same project instance as their receiver. Project fragments do +not construct work class instances. ## Synthesized helper methods -The compiler may synthesize additional work-class methods when the declared work prototype requires them. +The compiler may synthesize additional work class methods when the declared work prototype type requires them. ### `Classfname` -If the work prototype contains a method named `Classfname`, the compiler generates: +If the work prototype type contains a method named `Classfname`, the compiler generates: ```xgo func (this *T) Classfname() string @@ -476,9 +576,9 @@ Examples: ### `Classclone` -If the work prototype contains a method named `Classclone`, the compiler generates a shallow-clone method named -`Classclone` with no parameters other than the receiver. Its result list is adopted from the prototype's `Classclone` -declaration. +If the work prototype type contains a method named `Classclone`, the compiler generates a shallow-clone method named +`Classclone` with no parameters other than the receiver. Its result list is adopted from the work prototype type's +`Classclone` declaration. The generated implementation copies `*this` by value into a temporary variable and returns the address of that temporary value. @@ -491,11 +591,11 @@ exists. If one exists, no class-based package `main` is synthesized. If none exists, the compiler selects a class entrypoint as follows: -1. It considers only non-test framework registrations -2. Among framework project groups, it prefers a unique project group whose explicit project file has a shadow entry -3. If no such group exists, it prefers a unique remaining project group, including one that is represented only by a - synthesized default project class -4. If no project group is selected, it selects the unique normal classfile that has a shadow entry, if exactly one +1. Among active non-test framework project groups, it prefers a unique project group whose explicit project classfile + has a shadow entry +2. If no such group exists, it prefers a unique remaining active non-test project group, including one that is + represented only by a synthesized default project class +3. If no project group is selected, it selects the unique normal classfile that has a shadow entry, if exactly one exists If this process selects a class type `T`, the compiler generates: @@ -507,6 +607,42 @@ func main() { new(T).Main() } If no class type is selected, the compiler generates an empty `main` function unless automatic main generation is disabled in compiler configuration. +## Pack roots and pack documents + +One active project group may derive zero or one pack document from one classfile project. + +If one active project group declares one `pack` directive, its pack root is the resolved directory named by that +directive. + +Active project groups do not share pack roots or pack documents. Each active project group derives its own pack document +independently from its own `pack` directive, if any. + +For one active project group whose `pack` directive resolves to one pack root `R` and one index filename `F`, the +corresponding pack document is one logical object tree derived as follows: +- `R/F` is parsed as one object value +- each descendant directory of `R` that contains `F` contributes one child object +- each such child object is parsed from that descendant `F` and merged into the root object at the relative directory + path from `R`, creating intermediate objects as needed +- a contributing empty object remains present at its relative directory path +- directories that do not contain `F` contribute no object of their own +- files other than contributing `F` files are outside the standardized pack-document model + +If the project group declares no `pack` directive, no standardized pack document is derived for that project group. + +If `R/F` does not exist, if `R/F` or any contributing descendant `F` cannot be read or parsed as one object value, or if +one merge would overwrite an existing key at the same object level, no standardized pack document is derived for that +project group. + +The project files remain the source of truth. Each pack document is derived from one analyzed project state and one +active project group. + +An implementation may materialize one pack document as one generated sibling file of its root configuration file named: +- `index_pack.json` if `F` ends in `.json` +- `index_pack.yml` if `F` ends in `.yml` +- `index_pack.yaml` if `F` ends in `.yaml` + +Its enablement mechanism is implementation-defined. + ## Compatibility The following compatibility aliases are also accepted: @@ -524,5 +660,5 @@ Accordingly: package semantics - Package initialization order for ordinary package variables is unchanged -The classfile mechanism therefore adds a source-level lowering rule. It does not add a new runtime object model beyond -what is produced by the lowered Go code. +The classfile mechanism therefore adds a source-level lowering rule and preserves the ordinary runtime object model +produced by the lowered Go code. From e3522685fc77a39573820079e36ce67ccfa85ddb Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Tue, 14 Apr 2026 18:50:00 +0800 Subject: [PATCH 2/3] feat(classfile): add resource schema reader Add `cl/outline/classfile` to load resource schema from one typed framework package. This reader collects resource kinds, discovery directives, name-discovery directives, handle-bearing types, and `resource-api-scope-binding` directives from Go and XGo source. Also fix `outline.NewPackage` to load Go-file constant expressions when reading package outlines. This makes the schema reader work on real Go framework packages such as spx, not just on synthetic test packages. Updates #2704 Signed-off-by: Aofei Sheng --- cl/compile.go | 24 +- cl/outline/classfile/loader.go | 588 ++++++++++++++++++++++++++++ cl/outline/classfile/loader_test.go | 319 +++++++++++++++ cl/outline/classfile/parse.go | 230 +++++++++++ cl/outline/classfile/parse_test.go | 162 ++++++++ cl/outline/classfile/schema.go | 162 ++++++++ cl/outline/classfile/schema_test.go | 558 ++++++++++++++++++++++++++ cl/outline/outline_test.go | 100 +++++ cl/type_and_var.go | 12 +- 9 files changed, 2151 insertions(+), 4 deletions(-) create mode 100644 cl/outline/classfile/loader.go create mode 100644 cl/outline/classfile/loader_test.go create mode 100644 cl/outline/classfile/parse.go create mode 100644 cl/outline/classfile/parse_test.go create mode 100644 cl/outline/classfile/schema.go create mode 100644 cl/outline/classfile/schema_test.go create mode 100644 cl/outline/outline_test.go diff --git a/cl/compile.go b/cl/compile.go index 50bb3643e..01c8563b7 100644 --- a/cl/compile.go +++ b/cl/compile.go @@ -22,6 +22,7 @@ import ( "go/constant" "go/types" "log" + "os" "reflect" "sort" "strconv" @@ -213,6 +214,7 @@ type Config struct { type nodeInterp struct { fset *token.FileSet files map[string]*ast.File + codes map[string][]byte relBaseDir string } @@ -235,9 +237,17 @@ func (p *nodeInterp) LoadExpr(node ast.Node) string { return "" } pos := p.fset.Position(start) - f := p.files[pos.Filename] n := int(node.End() - start) - return string(f.Code[pos.Offset : pos.Offset+n]) + code := p.codes[pos.Filename] + if code == nil { + src, err := os.ReadFile(pos.Filename) + if err != nil { + return "" + } + code = src + p.codes[pos.Filename] = code + } + return string(code[pos.Offset : pos.Offset+n]) } func (p *nodeInterp) ProjFile() *ast.File { @@ -481,6 +491,7 @@ type pkgCtx struct { goxMainClass string goxMain int // normal gox files with main func idxConstName int // index of const name for auto rename + outline bool } type pkgImp struct { @@ -706,7 +717,13 @@ func NewPackage(pkgPath string, pkg *ast.Package, conf *Config) (p *gogen.Packag fset := conf.Fset files := pkg.Files interp := &nodeInterp{ - fset: fset, files: files, relBaseDir: relBaseDir, + fset: fset, + files: files, + codes: make(map[string][]byte, len(pkg.Files)), + relBaseDir: relBaseDir, + } + for filename, f := range files { + interp.codes[filename] = f.Code } ctx := &pkgCtx{ fset: fset, @@ -716,6 +733,7 @@ func NewPackage(pkgPath string, pkg *ast.Package, conf *Config) (p *gogen.Packag overpos: make(map[string]token.Pos), syms: make(map[string]loader), generics: make(map[string]bool), + outline: conf.Outline, } confGox := &gogen.Config{ Types: conf.Types, diff --git a/cl/outline/classfile/loader.go b/cl/outline/classfile/loader.go new file mode 100644 index 000000000..20e3ccc91 --- /dev/null +++ b/cl/outline/classfile/loader.go @@ -0,0 +1,588 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package classfile + +import ( + "fmt" + goast "go/ast" + gotoken "go/token" + "go/types" + "maps" + "slices" + "strings" + + xast "github.com/goplus/xgo/ast" + "github.com/goplus/xgo/scanner" + "github.com/goplus/xgo/token" +) + +// resourceSchemaLoader builds one [ResourceSchema] from one typed framework package. +type resourceSchemaLoader struct { + fset *token.FileSet + pkg *xast.Package + types *types.Package + schema *ResourceSchema + errs scanner.ErrorList + + rawAPIScopeBindings map[*types.Func][]rawAPIScopeBinding + callableHandles map[*types.Func]*types.TypeName +} + +// rawAPIScopeBinding is one unresolved API-position scope binding directive. +type rawAPIScopeBinding struct { + pos token.Pos + target int + source ResourceAPIScopeSource +} + +// scanPackage scans all Go and XGo files of the framework package. +func (l *resourceSchemaLoader) scanPackage() { + l.rawAPIScopeBindings = make(map[*types.Func][]rawAPIScopeBinding) + l.callableHandles = make(map[*types.Func]*types.TypeName) + for _, name := range slices.Sorted(maps.Keys(l.pkg.Files)) { + l.scanXGoFile(l.pkg.Files[name]) + } + + for _, name := range slices.Sorted(maps.Keys(l.pkg.GoFiles)) { + l.scanGoFile(l.pkg.GoFiles[name]) + } +} + +// scanXGoFile scans one parsed XGo file. +func (l *resourceSchemaLoader) scanXGoFile(file *xast.File) { + for _, decl := range file.Decls { + switch d := decl.(type) { + case *xast.GenDecl: + if d.Tok == token.TYPE { + l.scanXGoTypeDecl(d) + } + case *xast.FuncDecl: + l.scanXGoFuncDecl(d) + } + } +} + +// scanGoFile scans one parsed Go file. +func (l *resourceSchemaLoader) scanGoFile(file *goast.File) { + for _, decl := range file.Decls { + switch d := decl.(type) { + case *goast.GenDecl: + if d.Tok == gotoken.TYPE { + l.scanGoTypeDecl(d) + } + case *goast.FuncDecl: + l.scanGoFuncDecl(d) + } + } +} + +// scanXGoTypeDecl scans one XGo type declaration for classfile resource directives. +func (l *resourceSchemaLoader) scanXGoTypeDecl(decl *xast.GenDecl) { + for _, rawSpec := range decl.Specs { + spec, ok := rawSpec.(*xast.TypeSpec) + if !ok || spec.TypeParams != nil || !spec.Name.IsExported() { + continue + } + l.scanXGoTypeSpec(spec.Name.Name, spec, parseDirectives(xgoDeclDoc(decl, spec))) + } +} + +// scanGoTypeDecl scans one Go type declaration for classfile resource directives. +func (l *resourceSchemaLoader) scanGoTypeDecl(decl *goast.GenDecl) { + for _, rawSpec := range decl.Specs { + spec, ok := rawSpec.(*goast.TypeSpec) + if !ok || spec.TypeParams != nil || !spec.Name.IsExported() { + continue + } + l.scanGoTypeSpec(spec.Name.Name, spec, parseDirectives(goDeclDoc(decl, spec))) + } +} + +// scanXGoTypeSpec loads classfile resource directives attached to one exported +// top-level XGo type spec. +func (l *resourceSchemaLoader) scanXGoTypeSpec(name string, spec *xast.TypeSpec, dirs directives) { + obj, kind, ok := l.scanTypeDirectives(name, dirs) + if !ok { + return + } + if iface, ok := types.Unalias(obj.Type()).Underlying().(*types.Interface); ok { + l.scanXGoInterfaceMethodSpecs(obj, kind, iface, spec) + } +} + +// scanGoTypeSpec loads classfile resource directives attached to one exported +// top-level Go type spec. +func (l *resourceSchemaLoader) scanGoTypeSpec(name string, spec *goast.TypeSpec, dirs directives) { + obj, kind, ok := l.scanTypeDirectives(name, dirs) + if !ok { + return + } + if iface, ok := types.Unalias(obj.Type()).Underlying().(*types.Interface); ok { + l.scanGoInterfaceMethodSpecs(obj, kind, iface, spec) + } +} + +// scanTypeDirectives loads classfile resource directives attached to one +// exported top-level type declaration. +func (l *resourceSchemaLoader) scanTypeDirectives(name string, dirs directives) (*types.TypeName, *ResourceKind, bool) { + l.addTypeDirectiveSyntaxErrors(dirs) + if len(dirs.resource) == 0 { + return nil, nil, false + } + obj := l.lookupType(name) + if obj == nil { + return nil, nil, false + } + if len(dirs.resource) > 1 { + l.addError(dirs.resource[1].pos, "duplicate resource directive on %s", name) + return nil, nil, false + } + kindName, ok := parseResourceKind(dirs.resource[0].arg) + if !ok { + l.addError(dirs.resource[0].pos, "invalid resource kind %q", dirs.resource[0].arg) + return nil, nil, false + } + + if isStringBased(obj) { + hasDiscovery := false + if len(dirs.discovery) > 1 { + l.addError(dirs.discovery[1].pos, "duplicate %s directive on %s", directiveResourceDiscovery, name) + } else if len(dirs.discovery) == 1 && + validDQLQuery(dirs.discovery[0].pos, dirs.discovery[0].arg, directiveResourceDiscovery) { + hasDiscovery = true + } + if hasDiscovery && len(dirs.nameDiscovery) > 1 { + l.addError(dirs.nameDiscovery[1].pos, "duplicate %s directive on %s", directiveResourceNameDiscovery, name) + } + kind := l.ensureKind(kindName, dirs.resource[0].pos) + if kind.CanonicalType != nil && kind.CanonicalType != obj { + l.addError( + dirs.resource[0].pos, + "resource kind %q has more than one canonical type (previous at %v)", + kindName, + l.fset.Position(kind.CanonicalType.Pos()), + ) + return nil, nil, false + } + kind.CanonicalType = obj + l.schema.byCanonical[obj] = kind + if hasDiscovery { + kind.DiscoveryQuery = dirs.discovery[0].arg + } + if hasDiscovery && len(dirs.nameDiscovery) == 1 && + validDQLQuery(dirs.nameDiscovery[0].pos, dirs.nameDiscovery[0].arg, directiveResourceNameDiscovery) { + kind.NameDiscoveryQuery = dirs.nameDiscovery[0].arg + } + return obj, kind, true + } + + if isHandleBearing(obj) { + if strings.Contains(kindName, ".") { + l.addError(dirs.resource[0].pos, "handle-bearing type %s must declare one top-level resource kind", name) + return nil, nil, false + } + kind := l.ensureKind(kindName, dirs.resource[0].pos) + kind.HandleTypes = append(kind.HandleTypes, obj) + l.schema.byHandle[obj] = kind + return obj, kind, true + } + + return nil, nil, false +} + +// scanXGoInterfaceMethodSpecs loads classfile scope-binding directives +// attached to method specs declared by one handle-bearing XGo interface type. +func (l *resourceSchemaLoader) scanXGoInterfaceMethodSpecs(obj *types.TypeName, kind *ResourceKind, iface *types.Interface, spec *xast.TypeSpec) { + xiface, ok := spec.Type.(*xast.InterfaceType) + if !ok || kind == nil || kind != l.schema.byHandle[obj] || xiface.Methods == nil { + return + } + for _, field := range xiface.Methods.List { + if len(field.Names) != 1 { + continue + } + dirs := parseDirectives(field.Doc) + l.addScopeBindingSyntaxErrors(dirs) + if len(dirs.apiScopeBindings) == 0 { + continue + } + fn := l.lookupInterfaceMethod(iface, field.Names[0].Name) + if fn == nil { + continue + } + l.callableHandles[fn] = obj + l.addRawAPIScopeBindings(fn, dirs.apiScopeBindings) + } +} + +// scanGoInterfaceMethodSpecs loads classfile scope-binding directives +// attached to method specs declared by one handle-bearing Go interface type. +func (l *resourceSchemaLoader) scanGoInterfaceMethodSpecs(obj *types.TypeName, kind *ResourceKind, iface *types.Interface, spec *goast.TypeSpec) { + goiface, ok := spec.Type.(*goast.InterfaceType) + if !ok || kind == nil || kind != l.schema.byHandle[obj] || goiface.Methods == nil { + return + } + for _, field := range goiface.Methods.List { + if len(field.Names) != 1 { + continue + } + dirs := parseDirectives((*xast.CommentGroup)(field.Doc)) + l.addScopeBindingSyntaxErrors(dirs) + if len(dirs.apiScopeBindings) == 0 { + continue + } + fn := l.lookupInterfaceMethod(iface, field.Names[0].Name) + if fn == nil { + continue + } + l.callableHandles[fn] = obj + l.addRawAPIScopeBindings(fn, dirs.apiScopeBindings) + } +} + +// scanXGoFuncDecl scans one XGo function or method declaration for classfile +// scope-binding directives. +func (l *resourceSchemaLoader) scanXGoFuncDecl(decl *xast.FuncDecl) { + dirs := parseDirectives(decl.Doc) + l.addScopeBindingSyntaxErrors(dirs) + if len(dirs.apiScopeBindings) == 0 { + return + } + fn := l.lookupXGoFunc(decl) + if fn == nil { + return + } + l.addRawAPIScopeBindings(fn, dirs.apiScopeBindings) +} + +// scanGoFuncDecl scans one Go function or method declaration for classfile +// scope-binding directives. +func (l *resourceSchemaLoader) scanGoFuncDecl(decl *goast.FuncDecl) { + dirs := parseDirectives(decl.Doc) + l.addScopeBindingSyntaxErrors(dirs) + if len(dirs.apiScopeBindings) == 0 { + return + } + fn := l.lookupGoFunc(decl) + if fn == nil { + return + } + l.addRawAPIScopeBindings(fn, dirs.apiScopeBindings) +} + +// addRawAPIScopeBindings records parsed scope bindings before type-driven validation. +func (l *resourceSchemaLoader) addRawAPIScopeBindings(fn *types.Func, dirs []apiScopeBindingDirective) { + seen := make(map[int]rawAPIScopeBinding) + invalid := make(map[int]bool) + for _, dir := range dirs { + if prev, ok := seen[dir.target]; ok { + l.addError(dir.pos, "duplicate %s target param.%d", directiveResourceAPIScopeBinding, dir.target) + if !invalid[dir.target] { + l.addError(prev.pos, "previous binding for target param.%d", dir.target) + } + invalid[dir.target] = true + continue + } + seen[dir.target] = rawAPIScopeBinding(dir) + } + for _, dir := range dirs { + if invalid[dir.target] { + continue + } + raw, ok := seen[dir.target] + if !ok { + continue + } + l.rawAPIScopeBindings[fn] = append(l.rawAPIScopeBindings[fn], raw) + delete(seen, dir.target) + } +} + +// validateKinds validates inter-kind constraints after scanning. +func (l *resourceSchemaLoader) validateKinds() { + for _, kind := range l.schema.Kinds { + if strings.Contains(kind.Name, ".") { + parent := kind.Name[:strings.LastIndexByte(kind.Name, '.')] + if _, ok := l.schema.Kind(parent); !ok { + l.addError(kind.pos, "resource kind %q declares undeclared direct parent kind %q", kind.Name, parent) + } + } + } +} + +// validateAPIScopeBindings validates raw API-position bindings and commits the ones +// with standardized meaning. +func (l *resourceSchemaLoader) validateAPIScopeBindings() { + for fn, raws := range l.rawAPIScopeBindings { + if len(raws) == 0 { + continue + } + sig := fn.Type().(*types.Signature) + var out []ResourceAPIScopeBinding + var valid []rawAPIScopeBinding + for _, raw := range raws { + targetKind, ok := l.kindOfTarget(sig, raw) + if !ok { + l.addError(raw.pos, "invalid resource-api-scope-binding target param.%d", raw.target) + continue + } + if !l.validSource(fn, sig, targetKind, raw) { + if raw.source.Receiver { + l.addError(raw.pos, "invalid resource-api-scope-binding source receiver for target param.%d", raw.target) + } else { + l.addError(raw.pos, "invalid resource-api-scope-binding source param.%d for target param.%d", raw.source.Param, raw.target) + } + continue + } + valid = append(valid, raw) + out = append(out, ResourceAPIScopeBinding{TargetParam: raw.target, Source: raw.source}) + } + if hasAPIScopeBindingCycle(valid) { + l.addError(fn.Pos(), "resource-api-scope-binding on %s induces a cycle", fn.FullName()) + continue + } + if len(out) != 0 { + l.schema.apiScopeBindings[fn] = out + } + } +} + +// kindOfTarget reports the scoped resource kind bound at the target parameter. +func (l *resourceSchemaLoader) kindOfTarget(sig *types.Signature, raw rawAPIScopeBinding) (*ResourceKind, bool) { + params := sig.Params() + if raw.target < 0 || raw.target >= params.Len() { + return nil, false + } + if sig.Variadic() && raw.target == params.Len()-1 { + return nil, false + } + kind, ok := l.schema.CanonicalKindOfType(params.At(raw.target).Type()) + if !ok || !strings.Contains(kind.Name, ".") { + return nil, false + } + return kind, true +} + +// validSource reports whether one scope-binding source is valid for the target +// scoped kind. +func (l *resourceSchemaLoader) validSource(fn *types.Func, sig *types.Signature, targetKind *ResourceKind, raw rawAPIScopeBinding) bool { + parentKind, ok := l.schema.Kind(targetKind.Name[:strings.LastIndexByte(targetKind.Name, '.')]) + if !ok { + return false + } + if raw.source.Receiver { + if recv := sig.Recv(); recv != nil && l.hasParentKind(recv.Type(), parentKind) { + return true + } + handle := l.callableHandles[fn] + return handle != nil && l.hasParentKind(handle.Type(), parentKind) + } + + params := sig.Params() + if raw.source.Param < 0 || raw.source.Param >= params.Len() { + return false + } + if sig.Variadic() && raw.source.Param == params.Len()-1 { + return false + } + return l.hasParentKind(params.At(raw.source.Param).Type(), parentKind) +} + +// lookupInterfaceMethod looks up one explicit method of one interface type. +func (l *resourceSchemaLoader) lookupInterfaceMethod(iface *types.Interface, methodName string) *types.Func { + for i := range iface.NumExplicitMethods() { + fn := iface.ExplicitMethod(i) + if fn.Name() == methodName { + return fn + } + } + return nil +} + +// hasParentKind reports whether typ determines parentKind as either a canonical +// resource type or a handle-bearing type. +func (l *resourceSchemaLoader) hasParentKind(typ types.Type, parentKind *ResourceKind) bool { + if kind, ok := l.schema.CanonicalKindOfType(typ); ok && kind == parentKind { + return true + } + if kind, ok := l.schema.HandleKindOfType(typ); ok && kind == parentKind { + return true + } + return false +} + +// lookupType looks up one top-level type name in the typed package scope. +func (l *resourceSchemaLoader) lookupType(name string) *types.TypeName { + obj, ok := l.types.Scope().Lookup(name).(*types.TypeName) + if !ok { + return nil + } + return obj +} + +// lookupXGoFunc looks up one typed XGo function or method declaration. +func (l *resourceSchemaLoader) lookupXGoFunc(decl *xast.FuncDecl) *types.Func { + if decl.Recv == nil { + obj, ok := l.types.Scope().Lookup(decl.Name.Name).(*types.Func) + if !ok { + return nil + } + return obj + } + name := xgoRecvBaseName(decl.Recv) + if name == "" { + return nil + } + return l.lookupMethod(name, decl.Name.Name) +} + +// lookupGoFunc looks up one typed Go function or method declaration. +func (l *resourceSchemaLoader) lookupGoFunc(decl *goast.FuncDecl) *types.Func { + if decl.Recv == nil { + obj, ok := l.types.Scope().Lookup(decl.Name.Name).(*types.Func) + if !ok { + return nil + } + return obj + } + name := goRecvBaseName(decl.Recv) + if name == "" { + return nil + } + return l.lookupMethod(name, decl.Name.Name) +} + +// lookupMethod looks up one method declared on the named receiver type. +func (l *resourceSchemaLoader) lookupMethod(recvName, methodName string) *types.Func { + obj, ok := l.types.Scope().Lookup(recvName).(*types.TypeName) + if !ok { + return nil + } + named, ok := types.Unalias(obj.Type()).(*types.Named) + if !ok { + return nil + } + for fn := range named.Methods() { + if fn.Name() == methodName { + return fn + } + } + return nil +} + +// ensureKind returns the resource kind record for name, creating it if needed. +func (l *resourceSchemaLoader) ensureKind(name string, pos token.Pos) *ResourceKind { + if ret, ok := l.schema.byKind[name]; ok { + return ret + } + ret := &ResourceKind{Name: name, pos: pos} + l.schema.byKind[name] = ret + l.schema.Kinds = append(l.schema.Kinds, ret) + return ret +} + +// addError records one schema-loading error at pos. +func (l *resourceSchemaLoader) addError(pos token.Pos, format string, args ...any) { + l.errs.Add(l.fset.Position(pos), fmt.Sprintf(format, args...)) +} + +// addTypeDirectiveSyntaxErrors reports malformed type-level classfile directives. +func (l *resourceSchemaLoader) addTypeDirectiveSyntaxErrors(dirs directives) { + for _, dir := range dirs.invalid { + switch dir.arg { + case directiveResource, directiveResourceDiscovery, directiveResourceNameDiscovery: + l.addError(dir.pos, "invalid %s directive syntax", dir.arg) + } + } +} + +// addScopeBindingSyntaxErrors reports malformed scope-binding directives. +func (l *resourceSchemaLoader) addScopeBindingSyntaxErrors(dirs directives) { + for _, dir := range dirs.invalid { + if dir.arg == directiveResourceAPIScopeBinding { + l.addError(dir.pos, "invalid %s directive syntax", directiveResourceAPIScopeBinding) + } + } +} + +// validDQLQuery reports whether arg may be accepted as one standard DQL query +// by this resource schema loader. +func validDQLQuery(pos token.Pos, arg, kind string) bool { + // TODO: Replace this placeholder with shared DQL validation once the + // standardized runtime DQL capability is available to classfile tooling. + return true +} + +// isStringBased reports whether obj is one exported string-based type. +func isStringBased(obj *types.TypeName) bool { + if !obj.Exported() { + return false + } + basic, ok := types.Unalias(obj.Type()).Underlying().(*types.Basic) + return ok && basic.Kind() == types.String +} + +// isHandleBearing reports whether obj is one exported defined interface or +// struct type. +func isHandleBearing(obj *types.TypeName) bool { + if !obj.Exported() || obj.IsAlias() { + return false + } + named, ok := types.Unalias(obj.Type()).(*types.Named) + if !ok { + return false + } + switch named.Underlying().(type) { + case *types.Interface, *types.Struct: + return true + default: + return false + } +} + +// hasAPIScopeBindingCycle reports whether parameter-to-parameter scope +// bindings induce a cycle. +func hasAPIScopeBindingCycle(apiScopeBindings []rawAPIScopeBinding) bool { + next := make(map[int]int) + for _, binding := range apiScopeBindings { + if !binding.source.Receiver { + next[binding.target] = binding.source.Param + } + } + seen := make(map[int]uint8) + var visit func(int) bool + visit = func(v int) bool { + switch seen[v] { + case 1: + return true + case 2: + return false + } + seen[v] = 1 + if to, ok := next[v]; ok && visit(to) { + return true + } + seen[v] = 2 + return false + } + for v := range next { + if visit(v) { + return true + } + } + return false +} diff --git a/cl/outline/classfile/loader_test.go b/cl/outline/classfile/loader_test.go new file mode 100644 index 000000000..94da7cc4c --- /dev/null +++ b/cl/outline/classfile/loader_test.go @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package classfile + +import ( + "go/types" + "testing" + + "github.com/goplus/xgo/token" +) + +func TestResourceSchemaLoaderAddRawAPIScopeBindings(t *testing.T) { + pkg := types.NewPackage("example.com/spx", "spx") + sig := types.NewSignatureType(nil, nil, nil, types.NewTuple(), types.NewTuple(), false) + fn := types.NewFunc(0, pkg, "SetCostume", sig) + l := resourceSchemaLoader{schema: &ResourceSchema{}, fset: token.NewFileSet()} + + l.addRawAPIScopeBindings(fn, []apiScopeBindingDirective{ + {target: 0, source: ResourceAPIScopeSource{Receiver: true}}, + {target: 0, source: ResourceAPIScopeSource{Param: 1}}, + }) + + if got := l.rawAPIScopeBindings[fn]; len(got) != 0 { + t.Fatalf("unexpected raw bindings: %#v", got) + } + if len(l.errs) != 2 { + t.Fatalf("unexpected error count: %d", len(l.errs)) + } +} + +func TestResourceSchemaLoaderValidateKinds(t *testing.T) { + fset := token.NewFileSet() + file := fset.AddFile("spx.go", fset.Base(), 64) + kindPos := file.Pos(12) + l := resourceSchemaLoader{ + fset: fset, + schema: &ResourceSchema{ + Kinds: []*ResourceKind{{Name: "sprite.costume", pos: kindPos}}, + byKind: map[string]*ResourceKind{ + "sprite.costume": {Name: "sprite.costume", pos: kindPos}, + }, + }, + } + + l.validateKinds() + + if len(l.errs) != 1 { + t.Fatalf("unexpected error count: %d", len(l.errs)) + } + if l.errs[0].Pos.Filename != "spx.go" || l.errs[0].Pos.Offset != 12 { + t.Fatalf("unexpected error position: %#v", l.errs[0].Pos) + } +} + +func TestResourceSchemaLoaderKindOfTarget(t *testing.T) { + l, _, costumeKind, spriteName, costumeName, _ := testResourceSchemaLoaderKinds() + + t.Run("ValidScopedCanonical", func(t *testing.T) { + sig := types.NewSignatureType( + nil, + nil, + nil, + types.NewTuple(types.NewVar(0, nil, "costume", costumeName)), + types.NewTuple(), + false, + ) + + got, ok := l.kindOfTarget(sig, rawAPIScopeBinding{target: 0}) + if !ok || got != costumeKind { + t.Fatalf("unexpected target kind: %#v, %v", got, ok) + } + }) + + t.Run("TopLevelCanonical", func(t *testing.T) { + sig := types.NewSignatureType( + nil, + nil, + nil, + types.NewTuple(types.NewVar(0, nil, "sprite", spriteName)), + types.NewTuple(), + false, + ) + + got, ok := l.kindOfTarget(sig, rawAPIScopeBinding{target: 0}) + if ok || got != nil { + t.Fatalf("unexpected target kind: %#v, %v", got, ok) + } + }) + + t.Run("VariadicLastParam", func(t *testing.T) { + sig := types.NewSignatureType( + nil, + nil, + nil, + types.NewTuple( + types.NewVar(0, nil, "costume", costumeName), + types.NewVar(0, nil, "rest", types.NewSlice(types.Typ[types.String])), + ), + types.NewTuple(), + true, + ) + + got, ok := l.kindOfTarget(sig, rawAPIScopeBinding{target: 1}) + if ok || got != nil { + t.Fatalf("unexpected variadic target kind: %#v, %v", got, ok) + } + }) +} + +func TestResourceSchemaLoaderValidSource(t *testing.T) { + l, _, costumeKind, spriteName, costumeName, spriteImpl := testResourceSchemaLoaderKinds() + + t.Run("ReceiverHandleBearing", func(t *testing.T) { + sig := types.NewSignatureType( + types.NewVar(0, nil, "recv", types.NewPointer(spriteImpl)), + nil, + nil, + types.NewTuple(types.NewVar(0, nil, "costume", costumeName)), + types.NewTuple(), + false, + ) + fn := types.NewFunc(0, nil, "SetCostume", sig) + + if !l.validSource(fn, sig, costumeKind, rawAPIScopeBinding{target: 0, source: ResourceAPIScopeSource{Receiver: true}}) { + t.Fatal("expected valid receiver source") + } + }) + + t.Run("CanonicalParam", func(t *testing.T) { + sig := types.NewSignatureType( + nil, + nil, + nil, + types.NewTuple( + types.NewVar(0, nil, "sprite", spriteName), + types.NewVar(0, nil, "costume", costumeName), + ), + types.NewTuple(), + false, + ) + fn := types.NewFunc(0, nil, "SetCostume", sig) + + if !l.validSource(fn, sig, costumeKind, rawAPIScopeBinding{target: 1, source: ResourceAPIScopeSource{Param: 0}}) { + t.Fatal("expected valid canonical param source") + } + }) + + t.Run("WrongKind", func(t *testing.T) { + sig := types.NewSignatureType( + nil, + nil, + nil, + types.NewTuple( + types.NewVar(0, nil, "costume", costumeName), + types.NewVar(0, nil, "sprite", spriteName), + ), + types.NewTuple(), + false, + ) + fn := types.NewFunc(0, nil, "SetCostume", sig) + + if l.validSource(fn, sig, costumeKind, rawAPIScopeBinding{target: 1, source: ResourceAPIScopeSource{Param: 0}}) { + t.Fatal("unexpected valid source") + } + }) + + t.Run("InterfaceReceiverHandleBearing", func(t *testing.T) { + pkg := types.NewPackage("example.com/spx", "spx") + sig := types.NewSignatureType( + nil, + nil, + nil, + types.NewTuple(types.NewVar(0, nil, "costume", costumeName)), + types.NewTuple(), + false, + ) + fn := types.NewFunc(0, pkg, "SetCostume", sig) + iface := types.NewInterfaceType([]*types.Func{fn}, nil) + iface.Complete() + spriteObj := types.NewTypeName(0, pkg, "Sprite", nil) + types.NewNamed(spriteObj, iface, nil) + interfaceMethod := iface.ExplicitMethod(0) + l.schema.byHandle[spriteObj] = l.schema.byKind["sprite"] + l.callableHandles = map[*types.Func]*types.TypeName{interfaceMethod: spriteObj} + + if !l.validSource(interfaceMethod, interfaceMethod.Type().(*types.Signature), costumeKind, rawAPIScopeBinding{target: 0, source: ResourceAPIScopeSource{Receiver: true}}) { + t.Fatal("expected valid interface receiver source") + } + }) +} + +func TestIsStringBased(t *testing.T) { + t.Run("StringAlias", func(t *testing.T) { + obj := types.NewTypeName(0, nil, "SpriteName", types.Typ[types.String]) + if !isStringBased(obj) { + t.Fatal("expected exported string-based type") + } + }) + + t.Run("Unexported", func(t *testing.T) { + obj := types.NewTypeName(0, nil, "spriteName", types.Typ[types.String]) + if isStringBased(obj) { + t.Fatal("unexpected unexported string-based type") + } + }) + + t.Run("NonString", func(t *testing.T) { + obj := types.NewTypeName(0, nil, "SpriteID", types.Typ[types.Int]) + if isStringBased(obj) { + t.Fatal("unexpected non-string type") + } + }) +} + +func TestIsHandleBearing(t *testing.T) { + t.Run("Struct", func(t *testing.T) { + obj := types.NewTypeName(0, nil, "SpriteImpl", nil) + named := types.NewNamed(obj, types.NewStruct(nil, nil), nil) + if !isHandleBearing(obj) { + t.Fatal("expected exported struct handle-bearing type") + } + if named.Obj() != obj { + t.Fatal("expected named type object identity") + } + }) + + t.Run("Interface", func(t *testing.T) { + obj := types.NewTypeName(0, nil, "Sprite", nil) + empty := types.NewInterfaceType(nil, nil) + empty.Complete() + types.NewNamed(obj, empty, nil) + if !isHandleBearing(obj) { + t.Fatal("expected exported interface handle-bearing type") + } + }) + + t.Run("Alias", func(t *testing.T) { + obj := types.NewTypeName(0, nil, "SpriteAlias", nil) + alias := types.NewAlias(obj, types.Typ[types.String]) + if isHandleBearing(alias.Obj()) { + t.Fatal("unexpected alias handle-bearing type") + } + }) +} + +func TestHasAPIScopeBindingCycle(t *testing.T) { + t.Run("Cyclic", func(t *testing.T) { + if !hasAPIScopeBindingCycle([]rawAPIScopeBinding{ + {target: 0, source: ResourceAPIScopeSource{Param: 1}}, + {target: 1, source: ResourceAPIScopeSource{Param: 0}}, + }) { + t.Fatal("expected cycle") + } + }) + + t.Run("Acyclic", func(t *testing.T) { + if hasAPIScopeBindingCycle([]rawAPIScopeBinding{ + {target: 0, source: ResourceAPIScopeSource{Receiver: true}}, + {target: 1, source: ResourceAPIScopeSource{Param: 0}}, + }) { + t.Fatal("unexpected cycle") + } + }) +} + +func testResourceSchemaLoaderKinds() (resourceSchemaLoader, *ResourceKind, *ResourceKind, *types.Alias, *types.Alias, *types.Named) { + pkg := types.NewPackage("example.com/spx", "spx") + + spriteNameObj := types.NewTypeName(0, pkg, "SpriteName", nil) + spriteName := types.NewAlias(spriteNameObj, types.Typ[types.String]) + + costumeNameObj := types.NewTypeName(0, pkg, "SpriteCostumeName", nil) + costumeName := types.NewAlias(costumeNameObj, types.Typ[types.String]) + + spriteImplObj := types.NewTypeName(0, pkg, "SpriteImpl", nil) + spriteImpl := types.NewNamed(spriteImplObj, types.NewStruct(nil, nil), nil) + + spriteKind := &ResourceKind{ + Name: "sprite", + CanonicalType: spriteNameObj, + HandleTypes: []*types.TypeName{spriteImplObj}, + } + costumeKind := &ResourceKind{ + Name: "sprite.costume", + CanonicalType: costumeNameObj, + } + + schema := &ResourceSchema{ + Package: pkg, + Kinds: []*ResourceKind{spriteKind, costumeKind}, + byKind: map[string]*ResourceKind{ + "sprite": spriteKind, + "sprite.costume": costumeKind, + }, + byCanonical: map[*types.TypeName]*ResourceKind{ + spriteNameObj: spriteKind, + costumeNameObj: costumeKind, + }, + byHandle: map[*types.TypeName]*ResourceKind{ + spriteImplObj: spriteKind, + }, + } + + return resourceSchemaLoader{fset: token.NewFileSet(), schema: schema}, spriteKind, costumeKind, spriteName, costumeName, spriteImpl +} diff --git a/cl/outline/classfile/parse.go b/cl/outline/classfile/parse.go new file mode 100644 index 000000000..0cfd3c28c --- /dev/null +++ b/cl/outline/classfile/parse.go @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package classfile + +import ( + goast "go/ast" + "strconv" + "strings" + + xast "github.com/goplus/xgo/ast" + "github.com/goplus/xgo/token" +) + +const directivePrefix = "//xgo:class:" + +const ( + directiveResource = "resource" + directiveResourceDiscovery = "resource-discovery" + directiveResourceNameDiscovery = "resource-name-discovery" + directiveResourceAPIScopeBinding = "resource-api-scope-binding" +) + +// directives groups parsed classfile directives attached to one declaration. +type directives struct { + resource []directive + discovery []directive + nameDiscovery []directive + apiScopeBindings []apiScopeBindingDirective + invalid []directive +} + +// directive is one parsed directive with its source position and argument. +type directive struct { + pos token.Pos + arg string +} + +// apiScopeBindingDirective is one parsed resource-api-scope-binding directive. +type apiScopeBindingDirective struct { + pos token.Pos + target int + source ResourceAPIScopeSource +} + +// parseDirectives parses classfile directives from one comment group. +func parseDirectives(doc *xast.CommentGroup) directives { + var ret directives + if doc == nil { + return ret + } + for _, comment := range doc.List { + if !strings.HasPrefix(comment.Text, directivePrefix) { + continue + } + body := strings.TrimSpace(strings.TrimPrefix(comment.Text, directivePrefix)) + switch { + case strings.HasPrefix(body, directiveResourceDiscovery+" "): + ret.discovery = append(ret.discovery, directive{ + pos: token.Pos(comment.Pos()), + arg: strings.TrimSpace(strings.TrimPrefix(body, directiveResourceDiscovery+" ")), + }) + case strings.HasPrefix(body, directiveResourceNameDiscovery+" "): + ret.nameDiscovery = append(ret.nameDiscovery, directive{ + pos: token.Pos(comment.Pos()), + arg: strings.TrimSpace(strings.TrimPrefix(body, directiveResourceNameDiscovery+" ")), + }) + case strings.HasPrefix(body, directiveResourceAPIScopeBinding+" "): + target, source, ok := parseScopeBinding( + strings.TrimSpace(strings.TrimPrefix(body, directiveResourceAPIScopeBinding+" ")), + ) + if ok { + ret.apiScopeBindings = append(ret.apiScopeBindings, apiScopeBindingDirective{ + pos: token.Pos(comment.Pos()), + target: target, + source: source, + }) + continue + } + ret.invalid = append(ret.invalid, directive{ + pos: token.Pos(comment.Pos()), + arg: directiveResourceAPIScopeBinding, + }) + case strings.HasPrefix(body, directiveResource+" "): + ret.resource = append(ret.resource, directive{ + pos: token.Pos(comment.Pos()), + arg: strings.TrimSpace(strings.TrimPrefix(body, directiveResource+" ")), + }) + case body == directiveResource, + body == directiveResourceDiscovery, + body == directiveResourceNameDiscovery, + body == directiveResourceAPIScopeBinding: + ret.invalid = append(ret.invalid, directive{pos: token.Pos(comment.Pos()), arg: body}) + } + } + return ret +} + +// parseResourceKind validates one canonical resource kind spelling. +func parseResourceKind(kind string) (string, bool) { + if kind == "" { + return "", false + } + for seg := range strings.SplitSeq(kind, ".") { + if seg == "" || seg[0] < 'a' || seg[0] > 'z' { + return "", false + } + for i := 1; i < len(seg); i++ { + ch := seg[i] + if ch == '_' || ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'z' { + continue + } + return "", false + } + } + return kind, true +} + +// parseScopeBinding parses one target-source API-position binding directive. +func parseScopeBinding(arg string) (int, ResourceAPIScopeSource, bool) { + const receiver = "receiver" + + fields := strings.Fields(arg) + if len(fields) != 2 { + return 0, ResourceAPIScopeSource{}, false + } + target, ok := parseParam(fields[0]) + if !ok { + return 0, ResourceAPIScopeSource{}, false + } + if fields[1] == receiver { + return target, ResourceAPIScopeSource{Receiver: true}, true + } + sourceParam, ok := parseParam(fields[1]) + if !ok { + return 0, ResourceAPIScopeSource{}, false + } + return target, ResourceAPIScopeSource{Param: sourceParam}, true +} + +// parseParam parses one param.N API-position operand. +func parseParam(v string) (int, bool) { + const paramPrefix = "param." + + if !strings.HasPrefix(v, paramPrefix) { + return 0, false + } + n := strings.TrimPrefix(v, paramPrefix) + if n == "" || len(n) > 1 && n[0] == '0' { + return 0, false + } + ret, err := strconv.Atoi(n) + if err != nil || ret < 0 { + return 0, false + } + return ret, true +} + +// xgoDeclDoc reports the effective doc group for one XGo type declaration. +func xgoDeclDoc(decl *xast.GenDecl, spec *xast.TypeSpec) *xast.CommentGroup { + if !decl.Lparen.IsValid() && decl.Doc != nil { + return decl.Doc + } + return spec.Doc +} + +// goDeclDoc reports the effective doc group for one Go type declaration. +func goDeclDoc(decl *goast.GenDecl, spec *goast.TypeSpec) *xast.CommentGroup { + if !decl.Lparen.IsValid() && decl.Doc != nil { + return (*xast.CommentGroup)(decl.Doc) + } + return (*xast.CommentGroup)(spec.Doc) +} + +// xgoRecvBaseName reports the receiver base type name for one XGo method. +func xgoRecvBaseName(recv *xast.FieldList) string { + if recv == nil || len(recv.List) == 0 { + return "" + } + return xgoExprBaseName(recv.List[0].Type) +} + +// xgoExprBaseName reports the base identifier of one XGo receiver type expression. +func xgoExprBaseName(expr xast.Expr) string { + switch v := expr.(type) { + case *xast.Ident: + return v.Name + case *xast.StarExpr: + return xgoExprBaseName(v.X) + case *xast.ParenExpr: + return xgoExprBaseName(v.X) + default: + return "" + } +} + +// goRecvBaseName reports the receiver base type name for one Go method. +func goRecvBaseName(recv *goast.FieldList) string { + if recv == nil || len(recv.List) == 0 { + return "" + } + return goExprBaseName(recv.List[0].Type) +} + +// goExprBaseName reports the base identifier of one Go receiver type expression. +func goExprBaseName(expr goast.Expr) string { + switch v := expr.(type) { + case *goast.Ident: + return v.Name + case *goast.StarExpr: + return goExprBaseName(v.X) + case *goast.ParenExpr: + return goExprBaseName(v.X) + default: + return "" + } +} diff --git a/cl/outline/classfile/parse_test.go b/cl/outline/classfile/parse_test.go new file mode 100644 index 000000000..f22acb3a7 --- /dev/null +++ b/cl/outline/classfile/parse_test.go @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package classfile + +import ( + goast "go/ast" + "testing" + + xast "github.com/goplus/xgo/ast" + "github.com/goplus/xgo/token" +) + +func TestParseDirectives(t *testing.T) { + t.Run("ResourceDirectives", func(t *testing.T) { + doc := &xast.CommentGroup{ + List: []*xast.Comment{ + {Text: "//xgo:class:resource sprite"}, + {Text: "//xgo:class:resource-discovery sprites.*"}, + {Text: "//xgo:class:resource-name-discovery id"}, + }, + } + + dirs := parseDirectives(doc) + if len(dirs.resource) != 1 || dirs.resource[0].arg != "sprite" { + t.Fatalf("unexpected resource directives: %#v", dirs.resource) + } + if len(dirs.discovery) != 1 || dirs.discovery[0].arg != "sprites.*" { + t.Fatalf("unexpected discovery directives: %#v", dirs.discovery) + } + if len(dirs.nameDiscovery) != 1 || dirs.nameDiscovery[0].arg != "id" { + t.Fatalf("unexpected name-discovery directives: %#v", dirs.nameDiscovery) + } + }) + + t.Run("InvalidDirectiveSyntax", func(t *testing.T) { + doc := &xast.CommentGroup{ + List: []*xast.Comment{ + {Text: "//xgo:class:resource"}, + {Text: "//xgo:class:resource-api-scope-binding param.x receiver"}, + }, + } + + dirs := parseDirectives(doc) + if len(dirs.invalid) != 2 { + t.Fatalf("unexpected invalid directives: %#v", dirs.invalid) + } + }) +} + +func TestParseResourceKind(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + ret, ok := parseResourceKind("sprite.costume_frame2") + if !ok || ret != "sprite.costume_frame2" { + t.Fatalf("unexpected parse result: %q, %v", ret, ok) + } + }) + + t.Run("Invalid", func(t *testing.T) { + for _, kind := range []string{"", "Sprite", "sprite..costume", "sprite.Costume"} { + if _, ok := parseResourceKind(kind); ok { + t.Fatalf("expected invalid kind: %q", kind) + } + } + }) +} + +func TestParseScopeBinding(t *testing.T) { + t.Run("ReceiverSource", func(t *testing.T) { + target, source, ok := parseScopeBinding("param.0 receiver") + if !ok || target != 0 || !source.Receiver || source.Param != 0 { + t.Fatalf("unexpected scope binding: %d, %#v, %v", target, source, ok) + } + }) + + t.Run("ParamSource", func(t *testing.T) { + target, source, ok := parseScopeBinding("param.1 param.0") + if !ok || target != 1 || source.Receiver || source.Param != 0 { + t.Fatalf("unexpected scope binding: %d, %#v, %v", target, source, ok) + } + }) + + t.Run("Invalid", func(t *testing.T) { + for _, arg := range []string{"receiver param.0", "param.x receiver", "param.0"} { + if _, _, ok := parseScopeBinding(arg); ok { + t.Fatalf("expected invalid scope binding: %q", arg) + } + } + }) +} + +func TestParseParam(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + ret, ok := parseParam("param.12") + if !ok || ret != 12 { + t.Fatalf("unexpected param parse result: %d, %v", ret, ok) + } + }) + + t.Run("Invalid", func(t *testing.T) { + for _, v := range []string{"param.", "param.01", "param.-1", "arg.0"} { + if _, ok := parseParam(v); ok { + t.Fatalf("expected invalid param: %q", v) + } + } + }) +} + +func TestDeclDoc(t *testing.T) { + t.Run("XGoDeclDoc", func(t *testing.T) { + declDoc := &xast.CommentGroup{List: []*xast.Comment{{Text: "// decl", Slash: token.Pos(1)}}} + specDoc := &xast.CommentGroup{List: []*xast.Comment{{Text: "// spec", Slash: token.Pos(2)}}} + decl := &xast.GenDecl{Doc: declDoc} + spec := &xast.TypeSpec{Doc: specDoc} + if got := xgoDeclDoc(decl, spec); got != declDoc { + t.Fatal("expected declaration doc to take precedence") + } + }) + + t.Run("GoDeclDoc", func(t *testing.T) { + declDoc := &goast.CommentGroup{List: []*goast.Comment{{Text: "// decl", Slash: 1}}} + specDoc := &goast.CommentGroup{List: []*goast.Comment{{Text: "// spec", Slash: 2}}} + decl := &goast.GenDecl{Doc: declDoc} + spec := &goast.TypeSpec{Doc: specDoc} + if got := goDeclDoc(decl, spec); got != (*xast.CommentGroup)(declDoc) { + t.Fatal("expected declaration doc to take precedence") + } + }) +} + +func TestRecvBaseName(t *testing.T) { + t.Run("XGo", func(t *testing.T) { + recv := &xast.FieldList{List: []*xast.Field{{ + Type: &xast.StarExpr{X: &xast.ParenExpr{X: &xast.Ident{Name: "SpriteImpl"}}}, + }}} + if got := xgoRecvBaseName(recv); got != "SpriteImpl" { + t.Fatalf("unexpected XGo receiver base name: %q", got) + } + }) + + t.Run("Go", func(t *testing.T) { + recv := &goast.FieldList{List: []*goast.Field{{ + Type: &goast.StarExpr{X: &goast.ParenExpr{X: &goast.Ident{Name: "SpriteImpl"}}}, + }}} + if got := goRecvBaseName(recv); got != "SpriteImpl" { + t.Fatalf("unexpected Go receiver base name: %q", got) + } + }) +} diff --git a/cl/outline/classfile/schema.go b/cl/outline/classfile/schema.go new file mode 100644 index 000000000..123dfb6d9 --- /dev/null +++ b/cl/outline/classfile/schema.go @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package classfile loads classfile-specific resource schema from one framework +// package. +package classfile + +import ( + "fmt" + "go/types" + "slices" + + "github.com/goplus/mod/modfile" + xast "github.com/goplus/xgo/ast" + "github.com/goplus/xgo/cl/outline" + "github.com/goplus/xgo/token" +) + +// ResourceSchema is the classfile resource schema loaded from one framework package. +type ResourceSchema struct { + Package *types.Package + Kinds []*ResourceKind + + byKind map[string]*ResourceKind + byCanonical map[*types.TypeName]*ResourceKind + byHandle map[*types.TypeName]*ResourceKind + apiScopeBindings map[*types.Func][]ResourceAPIScopeBinding +} + +// LoadResourceSchema loads one classfile resource schema from the framework +// package named by the first package path of proj. +func LoadResourceSchema(pkg *xast.Package, proj *modfile.Project, conf *outline.Config) (*ResourceSchema, error) { + if len(proj.PkgPaths) == 0 { + return nil, fmt.Errorf("project has no framework package path") + } + pkgPath := proj.PkgPaths[0] + + out, err := outline.NewPackage(pkgPath, pkg, conf) + if err != nil { + return nil, err + } + + loader := resourceSchemaLoader{ + fset: conf.Fset, + pkg: pkg, + types: out.Pkg(), + schema: &ResourceSchema{ + Package: out.Pkg(), + byKind: make(map[string]*ResourceKind), + byCanonical: make(map[*types.TypeName]*ResourceKind), + byHandle: make(map[*types.TypeName]*ResourceKind), + apiScopeBindings: make(map[*types.Func][]ResourceAPIScopeBinding), + }, + } + loader.scanPackage() + loader.validateKinds() + loader.validateAPIScopeBindings() + if len(loader.errs) != 0 { + return nil, loader.errs + } + return loader.schema, nil +} + +// Kind reports the resource kind by its canonical spelling. +func (s *ResourceSchema) Kind(name string) (*ResourceKind, bool) { + ret, ok := s.byKind[name] + return ret, ok +} + +// KindOfCanonical reports the resource kind declared by one canonical resource +// reference type declaration. +func (s *ResourceSchema) KindOfCanonical(obj *types.TypeName) (*ResourceKind, bool) { + ret, ok := s.byCanonical[obj] + return ret, ok +} + +// CanonicalKindOfType reports the canonical resource kind determined by typ by +// following alias declarations only. +func (s *ResourceSchema) CanonicalKindOfType(typ types.Type) (*ResourceKind, bool) { + for typ != nil { + switch t := typ.(type) { + case *types.Named: + return s.KindOfCanonical(t.Obj()) + case *types.Alias: + if kind, ok := s.KindOfCanonical(t.Obj()); ok { + return kind, true + } + typ = t.Rhs() + default: + return nil, false + } + } + return nil, false +} + +// KindOfHandle reports the resource kind declared by one handle-bearing type declaration. +func (s *ResourceSchema) KindOfHandle(obj *types.TypeName) (*ResourceKind, bool) { + ret, ok := s.byHandle[obj] + return ret, ok +} + +// HandleKindOfType reports the handle-bearing resource kind determined by typ. +func (s *ResourceSchema) HandleKindOfType(typ types.Type) (*ResourceKind, bool) { + for { + ptr, ok := typ.(*types.Pointer) + if !ok { + break + } + typ = ptr.Elem() + } + named, ok := typ.(*types.Named) + if !ok { + return nil, false + } + return s.KindOfHandle(named.Obj()) +} + +// APIScopeBindings reports the standardized API-position scope bindings +// declared on fn. +func (s *ResourceSchema) APIScopeBindings(fn *types.Func) []ResourceAPIScopeBinding { + ret := s.apiScopeBindings[fn] + if len(ret) == 0 { + return nil + } + return slices.Clone(ret) +} + +// ResourceKind is one resource kind declared in framework source. +type ResourceKind struct { + Name string + CanonicalType *types.TypeName + HandleTypes []*types.TypeName + DiscoveryQuery string + NameDiscoveryQuery string + + pos token.Pos +} + +// ResourceAPIScopeBinding is one standardized resource-api-scope-binding. +type ResourceAPIScopeBinding struct { + TargetParam int + Source ResourceAPIScopeSource +} + +// ResourceAPIScopeSource is one direct scope source of one API-position binding. +type ResourceAPIScopeSource struct { + Receiver bool + Param int +} diff --git a/cl/outline/classfile/schema_test.go b/cl/outline/classfile/schema_test.go new file mode 100644 index 000000000..0a6dda8d3 --- /dev/null +++ b/cl/outline/classfile/schema_test.go @@ -0,0 +1,558 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package classfile + +import ( + "go/types" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/mod/modfile" + xast "github.com/goplus/xgo/ast" + "github.com/goplus/xgo/cl/outline" + "github.com/goplus/xgo/parser" + "github.com/goplus/xgo/scanner" + "github.com/goplus/xgo/token" +) + +func TestLoadResourceSchema(t *testing.T) { + t.Run("ValidGoPackage", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.go": `package spx + +// SpriteName identifies a sprite by name. +// +//xgo:class:resource sprite +//xgo:class:resource-discovery sprites.* +type SpriteName = string + +type SpriteAlias = SpriteName + +// SpriteCostumeName identifies a sprite costume by name. +// +//xgo:class:resource sprite.costume +//xgo:class:resource-discovery costumes.* +type SpriteCostumeName = string + +// SpriteCostumeFrameName identifies one sprite costume frame by name. +// +//xgo:class:resource sprite.costume.frame +//xgo:class:resource-discovery frames.* +type SpriteCostumeFrameName = string + +// WidgetName identifies a widget by name. +// +//xgo:class:resource widget +//xgo:class:resource-discovery widgets.* +//xgo:class:resource-name-discovery id +type WidgetName = string + +// SpriteImpl is a handle-bearing type of the sprite resource kind. +// +//xgo:class:resource sprite +type SpriteImpl struct{} + +// SetCostume__0 sets the current costume. +// +//xgo:class:resource-api-scope-binding param.0 receiver +func (s *SpriteImpl) SetCostume__0(costume SpriteCostumeName) {} + +// SetCostumeAndFrame sets the current costume and frame. +// +//xgo:class:resource-api-scope-binding param.0 receiver +//xgo:class:resource-api-scope-binding param.1 param.0 +func (s *SpriteImpl) SetCostumeAndFrame(costume SpriteCostumeName, frame SpriteCostumeFrameName) {} +`, + }) + + schema, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err != nil { + t.Fatalf("LoadResourceSchema failed: %v", err) + } + + sprite, ok := schema.Kind("sprite") + if !ok { + t.Fatal("sprite kind not found") + } + if sprite.CanonicalType == nil || sprite.CanonicalType.Name() != "SpriteName" { + t.Fatalf("unexpected sprite canonical type: %#v", sprite.CanonicalType) + } + if sprite.DiscoveryQuery != "sprites.*" { + t.Fatalf("unexpected sprite discovery query: %q", sprite.DiscoveryQuery) + } + if len(sprite.HandleTypes) != 1 || sprite.HandleTypes[0].Name() != "SpriteImpl" { + t.Fatalf("unexpected sprite handle types: %#v", sprite.HandleTypes) + } + + widget, ok := schema.Kind("widget") + if !ok { + t.Fatal("widget kind not found") + } + if widget.NameDiscoveryQuery != "id" { + t.Fatalf("unexpected widget name-discovery query: %q", widget.NameDiscoveryQuery) + } + + if got, ok := schema.KindOfCanonical(sprite.CanonicalType); !ok || got != sprite { + t.Fatal("KindOfCanonical did not resolve sprite canonical type") + } + spriteAlias, ok := schema.Package.Scope().Lookup("SpriteAlias").(*types.TypeName) + if !ok { + t.Fatal("SpriteAlias type not found") + } + if got, ok := schema.CanonicalKindOfType(spriteAlias.Type()); !ok || got != sprite { + t.Fatal("CanonicalKindOfType did not follow alias chain to sprite") + } + if got, ok := schema.KindOfHandle(sprite.HandleTypes[0]); !ok || got != sprite { + t.Fatal("KindOfHandle did not resolve sprite handle type") + } + + handleNamed, ok := types.Unalias(sprite.HandleTypes[0].Type()).(*types.Named) + if !ok { + t.Fatalf("unexpected handle type: %T", sprite.HandleTypes[0].Type()) + } + if got, ok := schema.HandleKindOfType(types.NewPointer(handleNamed)); !ok || got != sprite { + t.Fatal("HandleKindOfType did not resolve sprite handle type") + } + + setCostume := lookupMethod(t, handleNamed, "SetCostume__0") + apiScopeBindings := schema.APIScopeBindings(setCostume) + if len(apiScopeBindings) != 1 || apiScopeBindings[0].TargetParam != 0 || !apiScopeBindings[0].Source.Receiver { + t.Fatalf("unexpected bindings for SetCostume__0: %#v", apiScopeBindings) + } + + setCostumeAndFrame := lookupMethod(t, handleNamed, "SetCostumeAndFrame") + apiScopeBindings = schema.APIScopeBindings(setCostumeAndFrame) + if len(apiScopeBindings) != 2 { + t.Fatalf("unexpected binding count for SetCostumeAndFrame: %#v", apiScopeBindings) + } + if apiScopeBindings[0].TargetParam != 0 || !apiScopeBindings[0].Source.Receiver { + t.Fatalf("unexpected first binding for SetCostumeAndFrame: %#v", apiScopeBindings[0]) + } + if apiScopeBindings[1].TargetParam != 1 || apiScopeBindings[1].Source.Receiver || apiScopeBindings[1].Source.Param != 0 { + t.Fatalf("unexpected second binding for SetCostumeAndFrame: %#v", apiScopeBindings[1]) + } + }) + + t.Run("ValidGoInterfaceMethodSpec", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.go": `package spx + +// SpriteName identifies a sprite by name. +// +//xgo:class:resource sprite +//xgo:class:resource-discovery sprites.* +type SpriteName = string + +// SpriteCostumeName identifies a sprite costume by name. +// +//xgo:class:resource sprite.costume +//xgo:class:resource-discovery costumes.* +type SpriteCostumeName = string + +// Sprite is a handle-bearing type of the sprite resource kind. +// +//xgo:class:resource sprite +type Sprite interface { + //xgo:class:resource-api-scope-binding param.0 receiver + SetCostume__0(costume SpriteCostumeName) +} +`, + }) + + schema, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err != nil { + t.Fatalf("LoadResourceSchema failed: %v", err) + } + + sprite, ok := schema.Kind("sprite") + if !ok { + t.Fatal("sprite kind not found") + } + if len(sprite.HandleTypes) != 1 || sprite.HandleTypes[0].Name() != "Sprite" { + t.Fatalf("unexpected sprite handle types: %#v", sprite.HandleTypes) + } + + handleNamed, ok := types.Unalias(sprite.HandleTypes[0].Type()).(*types.Named) + if !ok { + t.Fatalf("unexpected handle type: %T", sprite.HandleTypes[0].Type()) + } + iface, ok := handleNamed.Underlying().(*types.Interface) + if !ok { + t.Fatalf("unexpected handle underlying type: %T", handleNamed.Underlying()) + } + if iface.NumExplicitMethods() != 1 { + t.Fatalf("unexpected explicit method count: %d", iface.NumExplicitMethods()) + } + apiScopeBindings := schema.APIScopeBindings(iface.ExplicitMethod(0)) + if len(apiScopeBindings) != 1 || apiScopeBindings[0].TargetParam != 0 || !apiScopeBindings[0].Source.Receiver { + t.Fatalf("unexpected bindings for Sprite.SetCostume__0: %#v", apiScopeBindings) + } + }) + + t.Run("ValidXGoPackage", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.xgo": `package spx + +// SpriteName identifies a sprite by name. +// +//xgo:class:resource sprite +//xgo:class:resource-discovery sprites.* +type SpriteName = string + +// SpriteCostumeName identifies a sprite costume by name. +// +//xgo:class:resource sprite.costume +//xgo:class:resource-discovery costumes.* +type SpriteCostumeName = string + +// SpriteImpl is a handle-bearing type of the sprite resource kind. +// +//xgo:class:resource sprite +type SpriteImpl struct{} + +// SetCostume__0 sets the current costume. +// +//xgo:class:resource-api-scope-binding param.0 receiver +func (s *SpriteImpl) SetCostume__0(costume SpriteCostumeName) {} +`, + }) + + schema, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err != nil { + t.Fatalf("LoadResourceSchema failed: %v", err) + } + + sprite, ok := schema.Kind("sprite") + if !ok { + t.Fatal("sprite kind not found") + } + if sprite.CanonicalType == nil || sprite.CanonicalType.Name() != "SpriteName" { + t.Fatalf("unexpected sprite canonical type: %#v", sprite.CanonicalType) + } + if sprite.DiscoveryQuery != "sprites.*" { + t.Fatalf("unexpected sprite discovery query: %q", sprite.DiscoveryQuery) + } + if len(sprite.HandleTypes) != 1 || sprite.HandleTypes[0].Name() != "SpriteImpl" { + t.Fatalf("unexpected sprite handle types: %#v", sprite.HandleTypes) + } + + handleNamed, ok := types.Unalias(sprite.HandleTypes[0].Type()).(*types.Named) + if !ok { + t.Fatalf("unexpected handle type: %T", sprite.HandleTypes[0].Type()) + } + setCostume := lookupMethod(t, handleNamed, "SetCostume__0") + apiScopeBindings := schema.APIScopeBindings(setCostume) + if len(apiScopeBindings) != 1 || apiScopeBindings[0].TargetParam != 0 || !apiScopeBindings[0].Source.Receiver { + t.Fatalf("unexpected bindings for SetCostume__0: %#v", apiScopeBindings) + } + }) + + t.Run("GroupedTypeSpec", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.go": `package spx + +type ( + // SpriteName identifies a sprite by name. + // + //xgo:class:resource sprite + //xgo:class:resource-discovery sprites.* + SpriteName = string + + // SoundName identifies a sound by name. + // + //xgo:class:resource sound + //xgo:class:resource-discovery sounds.* + SoundName = string +) +`, + }) + + schema, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err != nil { + t.Fatalf("LoadResourceSchema failed: %v", err) + } + + sprite, ok := schema.Kind("sprite") + if !ok { + t.Fatal("sprite kind not found") + } + if sprite.CanonicalType == nil || sprite.CanonicalType.Name() != "SpriteName" { + t.Fatalf("unexpected sprite canonical type: %#v", sprite.CanonicalType) + } + if sprite.DiscoveryQuery != "sprites.*" { + t.Fatalf("unexpected sprite discovery query: %q", sprite.DiscoveryQuery) + } + + sound, ok := schema.Kind("sound") + if !ok { + t.Fatal("sound kind not found") + } + if sound.CanonicalType == nil || sound.CanonicalType.Name() != "SoundName" { + t.Fatalf("unexpected sound canonical type: %#v", sound.CanonicalType) + } + if sound.DiscoveryQuery != "sounds.*" { + t.Fatalf("unexpected sound discovery query: %q", sound.DiscoveryQuery) + } + }) + + t.Run("GroupedDeclarationDocIsIgnored", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.go": `package spx + +//xgo:class:resource sprite +//xgo:class:resource-discovery sprites.* +type ( + SpriteName = string + SoundName = string +) +`, + }) + + schema, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err != nil { + t.Fatalf("LoadResourceSchema failed: %v", err) + } + if len(schema.Kinds) != 0 { + t.Fatalf("unexpected kinds from grouped declaration doc: %#v", schema.Kinds) + } + }) + + t.Run("DuplicateCanonicalType", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.go": `package spx + +//xgo:class:resource sprite +//xgo:class:resource-discovery sprites.* +type SpriteName = string + +//xgo:class:resource sprite +//xgo:class:resource-discovery sprites.* +type SpriteName2 = string +`, + }) + + _, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err == nil || !strings.Contains(err.Error(), `resource kind "sprite" has more than one canonical type`) { + t.Fatalf("expected duplicate canonical type error, got %v", err) + } + if !strings.Contains(err.Error(), "previous at") { + t.Fatalf("expected duplicate canonical type error to include previous position, got %v", err) + } + }) + + t.Run("ScopedHandleBearingType", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.go": `package spx + +//xgo:class:resource sprite.costume +type SpriteCostumeImpl struct{} +`, + }) + + _, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err == nil || !strings.Contains(err.Error(), "must declare one top-level resource kind") { + t.Fatalf("expected scoped handle-bearing type error, got %v", err) + } + }) + + t.Run("InvalidBindingsDoNotTriggerCycle", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.go": `package spx + +//xgo:class:resource sprite +//xgo:class:resource-discovery sprites.* +type SpriteName = string + +//xgo:class:resource sprite.costume +//xgo:class:resource-discovery costumes.* +type SpriteCostumeName = string + +//xgo:class:resource sprite +type SpriteImpl struct{} + +//xgo:class:resource-api-scope-binding param.0 param.1 +//xgo:class:resource-api-scope-binding param.1 param.0 +func (s *SpriteImpl) SetCostume(costume SpriteCostumeName, unrelated string) {} +`, + }) + + _, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err == nil { + t.Fatal("expected invalid binding error") + } + if strings.Contains(err.Error(), "induces a cycle") { + t.Fatalf("unexpected cycle error for invalid bindings: %v", err) + } + if !strings.Contains(err.Error(), "invalid resource-api-scope-binding source param.1 for target param.0") { + t.Fatalf("expected invalid binding source error, got %v", err) + } + }) + + t.Run("DuplicateBindingTargetIsRejectedAsWhole", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.go": `package spx + +//xgo:class:resource sprite +//xgo:class:resource-discovery sprites.* +type SpriteName = string + +//xgo:class:resource sprite.costume +//xgo:class:resource-discovery costumes.* +type SpriteCostumeName = string + +//xgo:class:resource sprite +type SpriteImpl struct{} + +//xgo:class:resource-api-scope-binding param.0 receiver +//xgo:class:resource-api-scope-binding param.0 receiver +func (s *SpriteImpl) SetCostume(costume SpriteCostumeName) {} +`, + }) + + _, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err == nil { + t.Fatal("expected duplicate binding target error") + } + if !strings.Contains(err.Error(), "duplicate resource-api-scope-binding target param.0") { + t.Fatalf("expected duplicate target error, got %v", err) + } + }) + + t.Run("InvalidDirectiveSyntax", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.go": `package spx + +//xgo:class:resource sprite +//xgo:class:resource-discovery +type SpriteName = string + +//xgo:class:resource sprite +type SpriteImpl struct{} + +//xgo:class:resource-api-scope-binding param.x receiver +func (s *SpriteImpl) SetCostume__0(costume SpriteName) {} +`, + }) + + _, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err == nil { + t.Fatal("expected invalid directive syntax error") + } + errs, ok := err.(scanner.ErrorList) + if !ok { + t.Fatalf("expected scanner.ErrorList, got %T", err) + } + var foundDiscovery, foundBinding bool + for _, scanErr := range errs { + switch { + case strings.Contains(scanErr.Msg, "invalid resource-discovery directive syntax"): + foundDiscovery = true + case strings.Contains(scanErr.Msg, "invalid resource-api-scope-binding directive syntax"): + foundBinding = true + } + } + if !foundDiscovery { + t.Fatalf("expected invalid resource-discovery syntax error, got %v", err) + } + if !foundBinding { + t.Fatalf("expected invalid scope-binding syntax error, got %v", err) + } + }) + + t.Run("NameDiscoveryWithoutDiscoveryIsIgnored", func(t *testing.T) { + pkgPath := "example.com/spx" + pkg, conf := loadTestPackage(t, map[string]string{ + "spx.go": `package spx + +//xgo:class:resource widget +//xgo:class:resource-name-discovery id +//xgo:class:resource-name-discovery name +type WidgetName = string +`, + }) + + schema, err := LoadResourceSchema(pkg, &modfile.Project{PkgPaths: []string{pkgPath}}, conf) + if err != nil { + t.Fatalf("expected name-discovery directives without discovery to be ignored, got %v", err) + } + widget, ok := schema.Kind("widget") + if !ok { + t.Fatal("widget kind not found") + } + if widget.NameDiscoveryQuery != "" { + t.Fatalf("unexpected widget name-discovery query: %q", widget.NameDiscoveryQuery) + } + }) +} + +// loadTestPackage parses one temporary package and returns its XGo package +// representation together with one outline config. +func loadTestPackage(t *testing.T, files map[string]string) (*xast.Package, *outline.Config) { + t.Helper() + + dir := t.TempDir() + for name, content := range files { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(%q) failed: %v", path, err) + } + } + + fset := token.NewFileSet() + pkgs, err := parser.ParseDirEx(fset, dir, parser.Config{ + Mode: parser.ParseComments, + }) + if err != nil { + t.Fatalf("ParseDirEx(%q) failed: %v", dir, err) + } + if len(pkgs) != 1 { + t.Fatalf("expected exactly one package, got %d", len(pkgs)) + } + for _, pkg := range pkgs { + return pkg, &outline.Config{Fset: fset} + } + t.Fatal("no package returned") + return nil, nil +} + +// lookupMethod looks up one method of the named handle-bearing type. +func lookupMethod(t *testing.T, named *types.Named, name string) *types.Func { + t.Helper() + + methods := types.NewMethodSet(types.NewPointer(named)) + sel := methods.Lookup(named.Obj().Pkg(), name) + if sel == nil { + t.Fatalf("method %s not found on %s", name, named.Obj().Name()) + } + fn, ok := sel.Obj().(*types.Func) + if !ok { + t.Fatalf("selection %s is not one method", name) + } + return fn +} diff --git a/cl/outline/outline_test.go b/cl/outline/outline_test.go new file mode 100644 index 000000000..36a49747a --- /dev/null +++ b/cl/outline/outline_test.go @@ -0,0 +1,100 @@ +package outline + +import ( + "os" + "path/filepath" + "testing" + + "github.com/goplus/xgo/cl" + "github.com/goplus/xgo/parser" + "github.com/goplus/xgo/token" +) + +func TestNewPackage(t *testing.T) { + t.Run("GoConstExpressions", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "flags.go") + err := os.WriteFile(path, []byte(`package flags + +type dbgFlags int + +const ( + DbgFlagLoad dbgFlags = 1 << iota + DbgFlagInstr + DbgFlagAll = DbgFlagLoad | DbgFlagInstr +) +`), 0o644) + if err != nil { + t.Fatalf("WriteFile(%q) failed: %v", path, err) + } + + fset := token.NewFileSet() + pkgs, err := parser.ParseDirEx(fset, dir, parser.Config{Mode: parser.ParseComments}) + if err != nil { + t.Fatalf("ParseDirEx(%q) failed: %v", dir, err) + } + pkg := pkgs["flags"] + if pkg == nil { + t.Fatalf("flags package not found in %q", dir) + } + + out, err := NewPackage("example.com/flags", pkg, &Config{Fset: fset}) + if err != nil { + t.Fatalf("NewPackage failed: %v", err) + } + + if out.Pkg().Scope().Lookup("DbgFlagAll") == nil { + t.Fatal("DbgFlagAll not found in package scope") + } + }) + + t.Run("GoGenericTypeInstantiations", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "project.go") + err := os.WriteFile(path, []byte(`package project + +type StageShape = map[string]any + +type StageItemHandlers[T any] struct { + Sprite func(StageShape) (T, error) +} + +type SpriteConfig struct { + Handlers StageItemHandlers[*SpriteConfig] +} + +func AppendStageItems[T any](items []T, shape StageShape, handlers StageItemHandlers[T]) ([]T, error) { + return items, nil +} + +func (c *SpriteConfig) CloneHandlers() StageItemHandlers[*SpriteConfig] { + return c.Handlers +} +`), 0o644) + if err != nil { + t.Fatalf("WriteFile(%q) failed: %v", path, err) + } + + fset := token.NewFileSet() + pkgs, err := parser.ParseDirEx(fset, dir, parser.Config{Mode: parser.ParseComments}) + if err != nil { + t.Fatalf("ParseDirEx(%q) failed: %v", dir, err) + } + pkg := pkgs["project"] + if pkg == nil { + t.Fatalf("project package not found in %q", dir) + } + + cl.SetDisableRecover(true) + defer cl.SetDisableRecover(false) + + out, err := NewPackage("example.com/project", pkg, &Config{Fset: fset}) + if err != nil { + t.Fatalf("NewPackage failed: %v", err) + } + + if out.Pkg().Scope().Lookup("AppendStageItems") == nil { + t.Fatal("AppendStageItems not found in package scope") + } + }) +} diff --git a/cl/type_and_var.go b/cl/type_and_var.go index 945554be0..9e2204aae 100644 --- a/cl/type_and_var.go +++ b/cl/type_and_var.go @@ -636,7 +636,17 @@ func instantiate(ctx *blockCtx, exprX ast.Expr, indices ...ast.Expr) (types.Type for i, index := range indices { idx[i] = toType(ctx, index) } - typ := ctx.pkg.Instantiate(x, idx, exprX) + var typ types.Type + if ctx.outline { + var err error + typ, err = types.Instantiate(nil, x, idx, false) + if err != nil { + ctx.handleErrorf(exprX.Pos(), exprX.End(), "%v", err) + return types.Typ[types.Invalid], nil + } + } else { + typ = ctx.pkg.Instantiate(x, idx, exprX) + } if rec := ctx.recorder(); rec != nil { rec.instantiate(exprX, x, typ) } From aa1c13f449f0f6f73fa601c46d61304ea9f45481 Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Fri, 10 Apr 2026 08:55:01 +0800 Subject: [PATCH 3/3] feat(dql/maps): add runtime query evaluation --- dql/maps/eval.go | 507 ++++++++++++++++++++++++++++++++++++++++++ dql/maps/eval_test.go | 270 ++++++++++++++++++++++ dql/maps/maps.go | 32 ++- 3 files changed, 804 insertions(+), 5 deletions(-) create mode 100644 dql/maps/eval.go create mode 100644 dql/maps/eval_test.go diff --git a/dql/maps/eval.go b/dql/maps/eval.go new file mode 100644 index 000000000..be2b3c887 --- /dev/null +++ b/dql/maps/eval.go @@ -0,0 +1,507 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package maps + +import ( + "fmt" + "path" + "reflect" + "strconv" + "strings" + "sync" + + "github.com/goplus/xgo/ast" + "github.com/goplus/xgo/parser" + "github.com/goplus/xgo/token" +) + +type evalContext struct { + root NodeSet + self *Node +} + +type compiledQuery struct { + expr ast.Expr + err error +} + +var evalCache sync.Map + +// Eval evaluates query as a DQL query fragment relative to the implicit root +// created from src. +func Eval(query string, src any) (NodeSet, error) { + expr, err := compileQuery(query) + if err != nil { + return NodeSet{}, err + } + root, err := sourceForEval(src) + if err != nil { + return NodeSet{}, err + } + return evalNodeSetExpr(expr, evalContext{root: root}) +} + +func compileQuery(query string) (ast.Expr, error) { + if cached, ok := evalCache.Load(query); ok { + compiled := cached.(compiledQuery) + return compiled.expr, compiled.err + } + expr, err := parser.ParseExpr(query) + compiled := compiledQuery{expr: expr, err: err} + actual, loaded := evalCache.LoadOrStore(query, compiled) + if loaded { + compiled = actual.(compiledQuery) + } + return compiled.expr, compiled.err +} + +func sourceForEval(src any) (ret NodeSet, err error) { + defer func() { + if v := recover(); v != nil { + err = fmt.Errorf("dql/maps.Eval: %v", v) + } + }() + ret = Source(src) + return ret, nil +} + +func evalNodeSetExpr(expr ast.Expr, ctx evalContext) (NodeSet, error) { + switch x := expr.(type) { + case *ast.ParenExpr: + return evalNodeSetExpr(x.X, ctx) + case *ast.Ident: + if x.Name == "self" { + if ctx.self == nil { + return NodeSet{}, fmt.Errorf("dql/maps.Eval: self is unavailable here") + } + return Root(*ctx.self), nil + } + return ctx.root.XGo_Elem(unquoteName(x.Name)), nil + case *ast.BasicLit: + if x.Kind != token.STRING { + return NodeSet{}, fmt.Errorf("dql/maps.Eval: unsupported literal %q in node-set query", x.Value) + } + name, err := strconv.Unquote(x.Value) + if err != nil { + return NodeSet{}, err + } + return ctx.root.XGo_Elem(name), nil + case *ast.SelectorExpr: + base, err := evalNodeSetExpr(x.X, ctx) + if err != nil { + return NodeSet{}, err + } + switch x.Sel.Name { + case "*": + return base.XGo_Child(), nil + case "all", "_all": + return base.XGo_all(), nil + case "one", "_one": + return base.XGo_one(), nil + case "single", "_single": + return base.XGo_single(), nil + case "ok", "_ok", "name", "_name", "value", "_value": + return NodeSet{}, fmt.Errorf("dql/maps.Eval: selector %q yields a scalar value", x.Sel.Name) + default: + if strings.HasPrefix(x.Sel.Name, "$") { + return NodeSet{}, fmt.Errorf("dql/maps.Eval: selector %q yields a scalar value", x.Sel.Name) + } + return base.XGo_Elem(unquoteName(x.Sel.Name)), nil + } + case *ast.AnySelectorExpr: + base, err := evalNodeSetExpr(x.X, ctx) + if err != nil { + return NodeSet{}, err + } + name := unquoteName(x.Sel.Name) + if name == "*" { + name = "" + } + return base.XGo_Any(name), nil + case *ast.CondExpr: + base, err := evalNodeSetExpr(x.X, ctx) + if err != nil { + return NodeSet{}, err + } + if ident, ok := x.Cond.(*ast.Ident); ok { + return base.XGo_Select(unquoteName(ident.Name)), nil + } + return filterNodeSet(base, x.Cond) + case *ast.IndexExpr: + base, err := evalNodeSetExpr(x.X, ctx) + if err != nil { + return NodeSet{}, err + } + index, err := evalIndexExpr(x.Index) + if err != nil { + return NodeSet{}, err + } + return base.XGo_Index(index), nil + default: + return NodeSet{}, fmt.Errorf("dql/maps.Eval: unsupported node-set expression %T", expr) + } +} + +func filterNodeSet(base NodeSet, cond ast.Expr) (NodeSet, error) { + if base.Err != nil { + return base, nil + } + nodes := make([]Node, 0, 8) + var filterErr error + base.Data(func(node Node) bool { + ok, err := evalBoolExpr(cond, evalContext{root: Root(node), self: &node}) + if err != nil { + filterErr = err + return false + } + if ok { + nodes = append(nodes, node) + } + return true + }) + if filterErr != nil { + return NodeSet{}, filterErr + } + return Nodes(nodes...), nil +} + +func evalIndexExpr(expr ast.Expr) (int, error) { + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.INT { + return 0, fmt.Errorf("dql/maps.Eval: unsupported index expression %T", expr) + } + return strconv.Atoi(lit.Value) +} + +func evalBoolExpr(expr ast.Expr, ctx evalContext) (bool, error) { + switch x := expr.(type) { + case *ast.ParenExpr: + return evalBoolExpr(x.X, ctx) + case *ast.UnaryExpr: + if x.Op != token.NOT { + return false, fmt.Errorf("dql/maps.Eval: unsupported unary operator %s", x.Op) + } + ret, err := evalBoolExpr(x.X, ctx) + if err != nil { + return false, err + } + return !ret, nil + case *ast.BinaryExpr: + switch x.Op { + case token.LAND: + left, err := evalBoolExpr(x.X, ctx) + if err != nil || !left { + return left, err + } + right, err := evalBoolExpr(x.Y, ctx) + return left && right, err + case token.LOR: + left, err := evalBoolExpr(x.X, ctx) + if err != nil || left { + return left, err + } + right, err := evalBoolExpr(x.Y, ctx) + return right, err + default: + left, err := evalValueExpr(x.X, ctx) + if err != nil { + return false, err + } + right, err := evalValueExpr(x.Y, ctx) + if err != nil { + return false, err + } + return compareValues(x.Op, left, right) + } + default: + value, err := evalValueExpr(expr, ctx) + if err != nil { + return false, err + } + ret, ok := value.(bool) + if !ok { + return false, fmt.Errorf("dql/maps.Eval: expression %T is not boolean", expr) + } + return ret, nil + } +} + +func evalValueExpr(expr ast.Expr, ctx evalContext) (any, error) { + switch x := expr.(type) { + case *ast.ParenExpr: + return evalValueExpr(x.X, ctx) + case *ast.BasicLit: + return basicLiteralValue(x) + case *ast.EnvExpr: + if ctx.self == nil { + return nil, fmt.Errorf("dql/maps.Eval: %s is unavailable here", "$"+x.Name.Name) + } + return ctx.self.XGo_Attr__0(unquoteName(x.Name.Name)), nil + case *ast.Ident: + switch x.Name { + case "true": + return true, nil + case "false": + return false, nil + case "nil": + return nil, nil + default: + return nil, fmt.Errorf("dql/maps.Eval: unsupported value identifier %q", x.Name) + } + case *ast.SelectorExpr: + base, err := evalNodeSetExpr(x.X, ctx) + if err != nil { + return nil, err + } + switch x.Sel.Name { + case "name", "_name": + return base.XGo_name__0(), nil + case "value", "_value": + return base.XGo_value__0(), nil + case "ok", "_ok": + return base.XGo_ok(), nil + default: + if strings.HasPrefix(x.Sel.Name, "$") { + return base.XGo_Attr__0(unquoteName(strings.TrimPrefix(x.Sel.Name, "$"))), nil + } + return nil, fmt.Errorf("dql/maps.Eval: unsupported value selector %q", x.Sel.Name) + } + case *ast.CallExpr: + return evalCallExpr(x, ctx) + case *ast.UnaryExpr, *ast.BinaryExpr: + return evalBoolExpr(expr, ctx) + default: + return nil, fmt.Errorf("dql/maps.Eval: unsupported value expression %T", expr) + } +} + +func evalCallExpr(call *ast.CallExpr, ctx evalContext) (any, error) { + switch fun := call.Fun.(type) { + case *ast.Ident: + switch fun.Name { + case "match": + if len(call.Args) != 2 { + return nil, fmt.Errorf("dql/maps.Eval: match expects 2 arguments") + } + pattern, ok, err := evalStringExpr(call.Args[0], ctx) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("dql/maps.Eval: match pattern must be a string") + } + value, ok, err := evalStringExpr(call.Args[1], ctx) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("dql/maps.Eval: match value must be a string") + } + return path.Match(pattern, value) + default: + return nil, fmt.Errorf("dql/maps.Eval: unsupported call %q", fun.Name) + } + case *ast.SelectorExpr: + receiver, ok, err := evalStringExpr(fun.X, ctx) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("dql/maps.Eval: selector call receiver must be a string") + } + if len(call.Args) != 1 { + return nil, fmt.Errorf("dql/maps.Eval: selector call %q expects 1 argument", fun.Sel.Name) + } + arg, ok, err := evalStringExpr(call.Args[0], ctx) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("dql/maps.Eval: selector call argument for %q must be a string", fun.Sel.Name) + } + switch fun.Sel.Name { + case "contains": + return strings.Contains(receiver, arg), nil + case "hasPrefix": + return strings.HasPrefix(receiver, arg), nil + case "hasSuffix": + return strings.HasSuffix(receiver, arg), nil + default: + return nil, fmt.Errorf("dql/maps.Eval: unsupported selector call %q", fun.Sel.Name) + } + default: + return nil, fmt.Errorf("dql/maps.Eval: unsupported call expression %T", call.Fun) + } +} + +func evalStringExpr(expr ast.Expr, ctx evalContext) (string, bool, error) { + value, err := evalValueExpr(expr, ctx) + if err != nil { + return "", false, err + } + value = zeroLike(value, "") + text, ok := value.(string) + return text, ok, nil +} + +func basicLiteralValue(lit *ast.BasicLit) (any, error) { + switch lit.Kind { + case token.STRING: + return strconv.Unquote(lit.Value) + case token.INT: + return strconv.Atoi(lit.Value) + case token.FLOAT: + return strconv.ParseFloat(lit.Value, 64) + default: + return nil, fmt.Errorf("dql/maps.Eval: unsupported literal kind %s", lit.Kind) + } +} + +func compareValues(op token.Token, left, right any) (bool, error) { + left = zeroLike(left, right) + right = zeroLike(right, left) + if leftNumber, ok := asFloat(left); ok { + rightNumber, ok := asFloat(right) + if !ok { + return false, fmt.Errorf("dql/maps.Eval: cannot compare number with %T", right) + } + switch op { + case token.EQL: + return leftNumber == rightNumber, nil + case token.NEQ: + return leftNumber != rightNumber, nil + case token.GTR: + return leftNumber > rightNumber, nil + case token.GEQ: + return leftNumber >= rightNumber, nil + case token.LSS: + return leftNumber < rightNumber, nil + case token.LEQ: + return leftNumber <= rightNumber, nil + default: + return false, fmt.Errorf("dql/maps.Eval: unsupported comparison operator %s", op) + } + } + switch left := left.(type) { + case string: + right, ok := right.(string) + if !ok { + return false, fmt.Errorf("dql/maps.Eval: cannot compare string with %T", right) + } + switch op { + case token.EQL: + return left == right, nil + case token.NEQ: + return left != right, nil + case token.GTR: + return left > right, nil + case token.GEQ: + return left >= right, nil + case token.LSS: + return left < right, nil + case token.LEQ: + return left <= right, nil + default: + return false, fmt.Errorf("dql/maps.Eval: unsupported comparison operator %s", op) + } + case bool: + right, ok := right.(bool) + if !ok { + return false, fmt.Errorf("dql/maps.Eval: cannot compare bool with %T", right) + } + switch op { + case token.EQL: + return left == right, nil + case token.NEQ: + return left != right, nil + default: + return false, fmt.Errorf("dql/maps.Eval: unsupported comparison operator %s for bool", op) + } + case nil: + switch op { + case token.EQL: + return right == nil, nil + case token.NEQ: + return right != nil, nil + default: + return false, fmt.Errorf("dql/maps.Eval: unsupported comparison operator %s for nil", op) + } + default: + switch op { + case token.EQL: + return reflect.DeepEqual(left, right), nil + case token.NEQ: + return !reflect.DeepEqual(left, right), nil + default: + return false, fmt.Errorf("dql/maps.Eval: unsupported comparison between %T and %T", left, right) + } + } +} + +func asFloat(v any) (float64, bool) { + switch v := v.(type) { + case int: + return float64(v), true + case int8: + return float64(v), true + case int16: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case uint: + return float64(v), true + case uint8: + return float64(v), true + case uint16: + return float64(v), true + case uint32: + return float64(v), true + case uint64: + return float64(v), true + case float32: + return float64(v), true + case float64: + return v, true + default: + return 0, false + } +} + +func zeroLike(v, other any) any { + if v != nil { + return v + } + switch other.(type) { + case string: + return "" + case bool: + return false + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return float64(0) + default: + return nil + } +} + +func unquoteName(name string) string { + if unquoted, err := strconv.Unquote(name); err == nil { + return unquoted + } + return name +} diff --git a/dql/maps/eval_test.go b/dql/maps/eval_test.go new file mode 100644 index 000000000..32afa7d65 --- /dev/null +++ b/dql/maps/eval_test.go @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package maps + +import ( + "slices" + "testing" +) + +func TestEval(t *testing.T) { + projectDoc := map[string]any{ + "assets": map[string]any{ + "users": []any{ + map[string]any{"name": "ken", "role": "admin"}, + map[string]any{"name": "jane", "role": "user"}, + }, + "sprites": map[string]any{ + "Hero": map[string]any{ + "index.json": map[string]any{ + "costumes": []any{ + map[string]any{ + "name": "idle", + "frames": []any{ + map[string]any{"name": "f1"}, + map[string]any{"name": "f2"}, + }, + }, + map[string]any{ + "name": "run", + "frames": []any{ + map[string]any{"name": "f1"}, + }, + }, + }, + "zorder": []any{ + map[string]any{"name": "front"}, + map[string]any{"name": ""}, + map[string]any{"other": "skip"}, + }, + }, + }, + "Enemy": map[string]any{ + "index.json": map[string]any{ + "costumes": []any{ + map[string]any{ + "name": "idle", + "frames": []any{ + map[string]any{"name": "f1"}, + }, + }, + }, + }, + }, + }, + }, + } + + t.Run("WildcardChild", func(t *testing.T) { + nodes, err := Eval(`assets.sprites.*`, projectDoc) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + names := collectNames(nodes) + slices.Sort(names) + if want := []string{"Enemy", "Hero"}; !slices.Equal(names, want) { + t.Fatalf("collectNames() = %v, want %v", names, want) + } + }) + + t.Run("RelativeQuotedRoot", func(t *testing.T) { + hero := childNode(t, projectDoc, "assets", "sprites", "Hero") + nodes, err := Eval(`"index.json".costumes.*`, hero) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + names := collectStringAttr(nodes, "name") + if want := []string{"idle", "run"}; !slices.Equal(names, want) { + t.Fatalf("collectStringAttr() = %v, want %v", names, want) + } + }) + + t.Run("RelativeFrames", func(t *testing.T) { + hero := childNode(t, projectDoc, "assets", "sprites", "Hero") + costumes, err := Eval(`"index.json".costumes.*`, hero) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + first := firstNode(t, costumes) + frames, err := Eval(`frames.*`, first) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + names := collectStringAttr(frames, "name") + if want := []string{"f1", "f2"}; !slices.Equal(names, want) { + t.Fatalf("collectStringAttr() = %v, want %v", names, want) + } + }) + + t.Run("NameChild", func(t *testing.T) { + hero := childNode(t, projectDoc, "assets", "sprites", "Hero") + costumes, err := Eval(`"index.json".costumes.*`, hero) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + first := firstNode(t, costumes) + nameNodes, err := Eval(`name`, first) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + node := firstNode(t, nameNodes) + if node.Name != "name" { + t.Fatalf("node.Name = %q, want %q", node.Name, "name") + } + if text, ok := node.Value.(string); !ok || text != "idle" { + t.Fatalf("node.Value = %#v, want %q", node.Value, "idle") + } + }) + + t.Run("FilterExpr", func(t *testing.T) { + hero := childNode(t, projectDoc, "assets", "sprites", "Hero") + nodes, err := Eval(`"index.json".zorder.*@($name != "")`, hero) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + names := collectStringAttr(nodes, "name") + if want := []string{"front"}; !slices.Equal(names, want) { + t.Fatalf("collectStringAttr() = %v, want %v", names, want) + } + }) + + t.Run("SelectByName", func(t *testing.T) { + nodes, err := Eval(`assets.sprites.*@Hero`, projectDoc) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + names := collectNames(nodes) + if want := []string{"Hero"}; !slices.Equal(names, want) { + t.Fatalf("collectNames() = %v, want %v", names, want) + } + }) + + t.Run("Index", func(t *testing.T) { + nodes, err := Eval(`assets.users[0]`, projectDoc) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + names := collectStringAttr(nodes, "name") + if want := []string{"ken"}; !slices.Equal(names, want) { + t.Fatalf("collectStringAttr() = %v, want %v", names, want) + } + }) + + t.Run("Single", func(t *testing.T) { + nodes, err := Eval(`assets.users.*@($role == "admin")._single`, projectDoc) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + if nodes.Err != nil { + t.Fatalf("nodes.Err = %v, want nil", nodes.Err) + } + names := collectStringAttr(nodes, "name") + if want := []string{"ken"}; !slices.Equal(names, want) { + t.Fatalf("collectStringAttr() = %v, want %v", names, want) + } + }) + + t.Run("MatchCall", func(t *testing.T) { + nodes, err := Eval(`assets.users.*@match("k*", $name)`, projectDoc) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + names := collectStringAttr(nodes, "name") + if want := []string{"ken"}; !slices.Equal(names, want) { + t.Fatalf("collectStringAttr() = %v, want %v", names, want) + } + }) + + t.Run("StringMethodCall", func(t *testing.T) { + nodes, err := Eval(`assets.users.*@($name.hasPrefix("k"))`, projectDoc) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + names := collectStringAttr(nodes, "name") + if want := []string{"ken"}; !slices.Equal(names, want) { + t.Fatalf("collectStringAttr() = %v, want %v", names, want) + } + }) + + t.Run("AnySelector", func(t *testing.T) { + nodes, err := Eval(`assets.**.name`, projectDoc) + if err != nil { + t.Fatalf("Eval returned error: %v", err) + } + names := collectScalarStrings(nodes) + slices.Sort(names) + want := []string{"", "f1", "f1", "f1", "f2", "front", "idle", "idle", "jane", "ken", "run"} + if !slices.Equal(names, want) { + t.Fatalf("collectScalarStrings() = %v, want %v", names, want) + } + }) +} + +func collectNames(nodes NodeSet) []string { + ret := make([]string, 0, 8) + nodes.Data(func(node Node) bool { + ret = append(ret, node.Name) + return true + }) + return ret +} + +func collectStringAttr(nodes NodeSet, name string) []string { + ret := make([]string, 0, 8) + nodes.Data(func(node Node) bool { + if children, ok := node.Value.(map[string]any); ok { + if value, ok := children[name].(string); ok { + ret = append(ret, value) + } + } + return true + }) + return ret +} + +func collectScalarStrings(nodes NodeSet) []string { + ret := make([]string, 0, 8) + nodes.Data(func(node Node) bool { + if value, ok := node.Value.(string); ok { + ret = append(ret, value) + } + return true + }) + return ret +} + +func firstNode(t *testing.T, nodes NodeSet) Node { + t.Helper() + node, err := nodes.XGo_first() + if err != nil { + t.Fatalf("XGo_first() error = %v", err) + } + return node +} + +func childNode(t *testing.T, root any, path ...string) Node { + t.Helper() + current := Source(root) + for _, name := range path { + current = current.XGo_Elem(name) + } + node, err := current.XGo_first() + if err != nil { + t.Fatalf("childNode(%v) error = %v", path, err) + } + return node +} diff --git a/dql/maps/maps.go b/dql/maps/maps.go index 1bc082679..dd8b9bc40 100644 --- a/dql/maps/maps.go +++ b/dql/maps/maps.go @@ -191,6 +191,32 @@ func yieldChildNodes(node Node, yield func(Node) bool) bool { return true } +// XGo_Index returns a NodeSet containing the child node at the specified +// zero-based index. +// - [n] +func (p NodeSet) XGo_Index(index int) NodeSet { + if p.Err != nil { + return p + } + if index < 0 { + return NodeSet{} + } + return NodeSet{ + Data: func(yield func(Node) bool) { + p.Data(func(node Node) bool { + return yieldIndex(node, index, yield) + }) + }, + } +} + +func yieldIndex(node Node, index int, yield func(Node) bool) bool { + if children, ok := node.Value.([]any); ok && index < len(children) { + return yield(Node{Name: "", Value: children[index]}) + } + return true +} + // XGo_Any returns a NodeSet containing all descendant nodes (including the // nodes themselves) with the specified name. // If name is "", it returns all nodes. @@ -238,11 +264,7 @@ func yieldAnyNodes(name string, node Node, yield func(Node) bool) bool { // yieldAnyNode recursively traverses into v if it is a map[string]any or []any, // looking for descendant nodes matching name. func yieldAnyNode(name, k string, v any, yield func(Node) bool) bool { - switch v.(type) { - case map[string]any, []any: - return yieldAnyNodes(name, Node{Name: k, Value: v}, yield) - } - return true + return yieldAnyNodes(name, Node{Name: k, Value: v}, yield) } // -----------------------------------------------------------------------------