Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,14 @@ mod - Module support for Go/XGo
[![XGo](https://img.shields.io/badge/project-XGo-blue.svg)](https://github.com/goplus/xgo)

This repository holds packages for writing tools that work directly with Go/XGo module mechanics. That is, it is for direct manipulation of Go/XGo modules themselves.

## Project drivers

Framework metadata may attach a driver to the nearest preceding project:

```text
project main.spx Game example.com/framework math
driver v1 example.com/framework/cmd/xgodriver
```

The protocol must match `v[1-9][0-9]*`; `driver` is a single-line, non-duplicate directive and has no block form. `driverprotocol` defines the request model and argv codec, but parsing and structural validation do not authenticate files. Consumers must reject non-canonical or symlinked declaration paths and re-hash declaration bytes before trusting the supplied SHA-256 identity. `xgomod.ImportClassesResolved` validates the target snapshot and ordered class-module provenance without rediscovering the graph.
54 changes: 54 additions & 0 deletions driverprotocol/action_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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 driverprotocol

import (
"fmt"
"testing"
)

func TestActionValidate(t *testing.T) {
tests := []struct {
action Action
valid bool
}{
{ActionRun, true},
{ActionBuild, true},
{"", false},
{"publish", false},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%q", test.action), func(t *testing.T) {
err := test.action.Validate()
if (err == nil) != test.valid {
t.Fatalf("Action(%q).Validate() = %v, valid = %v", test.action, err, test.valid)
}
if !test.valid {
request := testRequest()
request.Action = test.action
requestErr := request.Validate()
if requestErr == nil || requestErr.Error() != err.Error() {
t.Fatalf("Request.Validate() = %v, Action.Validate() = %v", requestErr, err)
}
_, parseErr := Parse([]string{PreambleV1, string(test.action)})
if parseErr == nil || parseErr.Error() != err.Error() {
t.Fatalf("Parse() = %v, Action.Validate() = %v", parseErr, err)
}
}
})
}
}
204 changes: 204 additions & 0 deletions driverprotocol/argv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/*
* 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 driverprotocol

import (
"fmt"

"github.com/goplus/mod/xgomod"
)

// Encode returns deterministic argv following the driver executable.
func Encode(request Request) ([]string, error) {
if err := request.Validate(); err != nil {
return nil, err
}
args := []string{
PreambleV1,
string(request.Action),
option("project-dir", request.Project.Dir),
option("project-file", request.Project.File),
option("module-root", request.Project.ModuleRoot),
option("driver-package", request.DriverPackage),
option("selected-path", request.DriverOrigin.Selected.Path),
option("selected-version", request.DriverOrigin.Selected.Version),
option("origin-main", fmt.Sprint(request.DriverOrigin.Main)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fmt.Sprint on a known bool triggers the reflection-based formatting path plus an allocation. strconv.FormatBool(request.DriverOrigin.Main) is more direct. Cosmetic.

}
if request.DriverOrigin.Replace == nil {
args = append(args,
option("selected-dir", request.DriverOrigin.Selected.Dir),
option("selected-gomod", request.DriverOrigin.Selected.GoMod),
)
} else {
replacement := request.DriverOrigin.Replace
args = append(args,
option("replace-path", replacement.Path),
option("replace-version", replacement.Version),
option("replace-dir", replacement.Dir),
option("replace-gomod", replacement.GoMod),
)
}
args = append(args,
option("project-ext", request.Project.Extension),
option("project-full-ext", request.Project.FullExtension),
)
if request.Project.Pack != nil {
args = append(args,
option("pack-dir", request.Project.Pack.Directory),
option("pack-index", request.Project.Pack.IndexFile),
)
}
args = append(args,
option("declaration-file", request.Declaration.Path),
option("declaration-sha256", request.Declaration.SHA256),
option("go-command", request.Graph.GoCommand),
option("graph-work-dir", request.Graph.WorkDir),
option("go-work", request.Graph.GoWork),
)
for _, flag := range request.Graph.Flags {
args = append(args, option("graph-flag", flag))
}
for _, flag := range request.BuildFlags {
args = append(args, option("build-flag", flag))
}
if request.Action == ActionRun {
args = append(args, "--")
args = append(args, request.ApplicationArgs...)
} else {
args = append(args,
option("output", request.Output.Staging),
option("final-output", request.Output.Final),
)
}
return args, nil
}

// Parse decodes driver argv and rejects invalid structure; it does not authenticate referenced files.
func Parse(args []string) (Request, error) {
var request Request
if len(args) < 2 {
return request, fmt.Errorf("driverprotocol: request requires preamble and action")
}
if args[0] != PreambleV1 {
return request, fmt.Errorf("driverprotocol: unsupported preamble %q", args[0])
}
request.Version = Version1
request.Action = Action(args[1])
if err := request.Action.Validate(); err != nil {
return Request{}, err
}

optionArgs := args[2:]
if request.Action == ActionRun {
delimiter := -1
for i, arg := range optionArgs {
if arg == "--" {
delimiter = i
break
}
}
if delimiter < 0 {
return Request{}, fmt.Errorf("driverprotocol: run requires -- before application arguments")
}
request.ApplicationArgs = append([]string(nil), optionArgs[delimiter+1:]...)
optionArgs = optionArgs[:delimiter]
} else {
for _, arg := range optionArgs {
if arg == "--" {
return Request{}, fmt.Errorf("driverprotocol: build does not accept -- or positional arguments")
}
}
}

raw, err := parseOptions(optionArgs)
if err != nil {
return Request{}, err
}
for _, spec := range singularOptionSpecs {
if _, ok := raw.values[spec.name]; spec.required && !ok {
return Request{}, fmt.Errorf("driverprotocol: option --%s is required", spec.name)
}
}

request.Project = Project{
Dir: raw.values["project-dir"],
File: raw.values["project-file"],
ModuleRoot: raw.values["module-root"],
Extension: raw.values["project-ext"],
FullExtension: raw.values["project-full-ext"],
}
request.Declaration = xgomod.FileIdentity{
Path: raw.values["declaration-file"], SHA256: raw.values["declaration-sha256"],
}
hasPack, completePack := optionGroup(raw.values, "pack-dir", "pack-index")
if hasPack && !completePack {
return Request{}, fmt.Errorf("driverprotocol: pack options must be supplied as a complete group")
}
if hasPack {
request.Project.Pack = &Pack{Directory: raw.values["pack-dir"], IndexFile: raw.values["pack-index"]}
}

request.DriverPackage = raw.values["driver-package"]
request.DriverOrigin = xgomod.ResolvedModule{
Selected: xgomod.ModuleRef{
Path: raw.values["selected-path"],
Version: raw.values["selected-version"],
},
}
switch raw.values["origin-main"] {
case "true":
request.DriverOrigin.Main = true
case "false":
default:
return Request{}, fmt.Errorf("driverprotocol: invalid --origin-main %q: expected true or false", raw.values["origin-main"])
}
request.DriverOrigin.Selected.Dir = raw.values["selected-dir"]
request.DriverOrigin.Selected.GoMod = raw.values["selected-gomod"]
hasSelected, _ := optionGroup(raw.values, "selected-dir", "selected-gomod")
hasReplacement, completeReplacement := optionGroup(raw.values, replacementOptions...)
if hasReplacement && !completeReplacement {
return Request{}, fmt.Errorf("driverprotocol: replacement options must be supplied as a complete group")
}
if hasReplacement && hasSelected {
return Request{}, fmt.Errorf("driverprotocol: origin with replacement forbids --selected-dir and --selected-gomod")
}
if hasReplacement {
request.DriverOrigin.Replace = &xgomod.ModuleRef{
Path: raw.values["replace-path"],
Version: raw.values["replace-version"],
Dir: raw.values["replace-dir"],
GoMod: raw.values["replace-gomod"],
}
}

request.Graph = Graph{
GoCommand: raw.values["go-command"],
WorkDir: raw.values["graph-work-dir"],
GoWork: raw.values["go-work"],
Flags: append([]string(nil), raw.graphFlags...),
}
request.BuildFlags = append([]string(nil), raw.buildFlags...)
output, hasOutput := raw.values["output"]
final, hasFinal := raw.values["final-output"]
if hasOutput || hasFinal {
request.Output = &BuildOutput{Staging: output, Final: final}
}
if err := request.Validate(); err != nil {
return Request{}, err
}
return request, nil
}
118 changes: 118 additions & 0 deletions driverprotocol/argv_options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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 driverprotocol

import (
"fmt"
"strings"
)

type optionSpec struct {
name string
required bool
}

// singularOptionSpecs is both the accepted singular option set and the stable
// order in which missing required options are reported.
var singularOptionSpecs = []optionSpec{
{"project-dir", true},
{"project-file", true},
{"module-root", true},
{"driver-package", true},
{"selected-path", true},
{"selected-version", true},
{"origin-main", true},
{"selected-dir", false},
{"selected-gomod", false},
{"replace-path", false},
{"replace-version", false},
{"replace-dir", false},
{"replace-gomod", false},
{"project-ext", true},
{"project-full-ext", true},
{"pack-dir", false},
{"pack-index", false},
{"declaration-file", true},
{"declaration-sha256", true},
{"go-command", true},
{"graph-work-dir", true},
{"go-work", true},
{"output", false},
{"final-output", false},
}

var replacementOptions = []string{
"replace-path",
"replace-version",
"replace-dir",
"replace-gomod",
}

type rawOptions struct {
values map[string]string
graphFlags []string
buildFlags []string
}

func parseOptions(args []string) (rawOptions, error) {
raw := rawOptions{values: make(map[string]string)}
for _, arg := range args {
if !strings.HasPrefix(arg, "--") || arg == "--" {
return rawOptions{}, fmt.Errorf("driverprotocol: unexpected positional argument %q", arg)
}
name, value, ok := strings.Cut(strings.TrimPrefix(arg, "--"), "=")
if !ok || name == "" {
return rawOptions{}, fmt.Errorf("driverprotocol: option %q must use --name=value", arg)
}
switch name {
case "graph-flag":
raw.graphFlags = append(raw.graphFlags, value)
case "build-flag":
raw.buildFlags = append(raw.buildFlags, value)
default:
if !isSingularOption(name) {
return rawOptions{}, fmt.Errorf("driverprotocol: unknown option --%s", name)
}
if _, duplicate := raw.values[name]; duplicate {
return rawOptions{}, fmt.Errorf("driverprotocol: option --%s may not be repeated", name)
}
raw.values[name] = value
}
}
return raw, nil
}

func isSingularOption(name string) bool {
for _, spec := range singularOptionSpecs {
if spec.name == name {
return true
}
}
return false
}

func optionGroup(values map[string]string, names ...string) (present, complete bool) {
complete = true
for _, name := range names {
_, ok := values[name]
present = present || ok
complete = complete && ok
}
return
}

func option(name, value string) string { return "--" + name + "=" + value }
Loading