Skip to content
Merged
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
165 changes: 165 additions & 0 deletions compiler/checker/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package checker

import (
"fmt"
"sort"
"strconv"

"github.com/akonwi/ard/manifest"
)

const BuildModulePath = "ard/build"

type BuildOverride struct {
Name string
Value string
}

type BuildOptions struct {
Release bool
Overrides []BuildOverride
}

type effectiveBuildValue struct {
Type manifest.BuildValueType
Value any
}

type buildModule struct {
program *Program
symbols map[string]Symbol
}

func (m *buildModule) Path() string { return BuildModulePath }
func (m *buildModule) Program() *Program { return m.program }
func (m *buildModule) Get(name string) Symbol { return m.symbols[name] }
func (m *buildModule) Symbols() map[string]Symbol { return m.symbols }

func resolveBuildValues(config manifest.BuildConfig, options BuildOptions) (map[string]effectiveBuildValue, error) {
values := make(map[string]effectiveBuildValue, len(config.Values))
for name, declaration := range config.Values {
value, err := effectiveValue(name, declaration.Type, declaration.Default)
if err != nil {
return nil, err
}
values[name] = value
}

explicit := make(map[string]bool, len(options.Overrides))
for _, override := range options.Overrides {
if explicit[override.Name] {
return nil, fmt.Errorf("build value %q is defined more than once", override.Name)
}
explicit[override.Name] = true
declaration, ok := config.Values[override.Name]
if !ok {
return nil, fmt.Errorf("unknown build value %q", override.Name)
}
value, err := parseBuildOverride(override, declaration.Type)
if err != nil {
return nil, err
}
values[override.Name] = value
}

if options.Release {
missing := make([]string, 0)
for name, declaration := range config.Values {
if declaration.Release && !explicit[name] {
missing = append(missing, name)
}
}
sort.Strings(missing)
if len(missing) > 0 {
return nil, fmt.Errorf("release build requires explicit --define values for: %s", joinQuoted(missing))
}
}
return values, nil
}

func effectiveValue(name string, valueType manifest.BuildValueType, raw any) (effectiveBuildValue, error) {
switch valueType {
case manifest.BuildValueStr:
return effectiveBuildValue{Type: valueType, Value: raw.(string)}, nil
case manifest.BuildValueBool:
return effectiveBuildValue{Type: valueType, Value: raw.(bool)}, nil
case manifest.BuildValueInt:
value := raw.(int64)
parsed, err := strconv.ParseInt(strconv.FormatInt(value, 10), 10, strconv.IntSize)
if err != nil {
return effectiveBuildValue{}, fmt.Errorf("build value %q default overflows Int", name)
}
return effectiveBuildValue{Type: valueType, Value: int(parsed)}, nil
default:
return effectiveBuildValue{}, fmt.Errorf("build value %q has unsupported type %q", name, valueType)
}
}

func parseBuildOverride(override BuildOverride, valueType manifest.BuildValueType) (effectiveBuildValue, error) {
switch valueType {
case manifest.BuildValueStr:
return effectiveBuildValue{Type: valueType, Value: override.Value}, nil
case manifest.BuildValueBool:
if override.Value != "true" && override.Value != "false" {
return effectiveBuildValue{}, fmt.Errorf("build value %q must be Bool (true or false)", override.Name)
}
return effectiveBuildValue{Type: valueType, Value: override.Value == "true"}, nil
case manifest.BuildValueInt:
value, err := strconv.ParseInt(override.Value, 10, strconv.IntSize)
if err != nil {
return effectiveBuildValue{}, fmt.Errorf("build value %q must be a decimal Int", override.Name)
}
return effectiveBuildValue{Type: valueType, Value: int(value)}, nil
default:
return effectiveBuildValue{}, fmt.Errorf("build value %q has unsupported type %q", override.Name, valueType)
}
}

func newBuildModule(values map[string]effectiveBuildValue) Module {
program := &Program{
Imports: map[string]Module{},
GoImports: map[string]*GoPackage{},
StructMethods: map[MethodOwner]map[string]*FunctionDef{},
InherentMethods: map[MethodOwner]map[string]*FunctionDef{},
TraitMethods: map[TraitMethodOwner]map[string]*FunctionDef{},
AmbiguousTraitMethods: map[MethodOwner]map[string]bool{},
RequiredGoMethods: map[MethodOwner]map[string]*FunctionDef{},
ForeignInterfaceImpls: map[MethodOwner][]*ForeignType{},
}
symbols := make(map[string]Symbol, len(values))
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
value := values[name]
var typ Type
var expression Expression
switch value.Type {
case manifest.BuildValueStr:
typ = Str
expression = &StrLiteral{Value: value.Value.(string)}
case manifest.BuildValueBool:
typ = Bool
expression = &BoolLiteral{Value: value.Value.(bool)}
case manifest.BuildValueInt:
typ = Int
expression = &IntLiteral{Value: value.Value.(int)}
}
program.Statements = append(program.Statements, Statement{Stmt: &VariableDef{Name: name, __type: typ, Value: expression}})
symbols[name] = Symbol{Name: name, Type: typ}
}
return &buildModule{program: program, symbols: symbols}
}

func joinQuoted(names []string) string {
result := ""
for i, name := range names {
if i > 0 {
result += ", "
}
result += strconv.Quote(name)
}
return result
}
172 changes: 172 additions & 0 deletions compiler/checker/build_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package checker

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/akonwi/ard/manifest"
"github.com/akonwi/ard/parse"
)

func TestResolveBuildValues(t *testing.T) {
config := manifest.BuildConfig{Values: map[string]manifest.BuildValue{
"version": {Type: manifest.BuildValueStr, Default: "dev", Release: true},
"number": {Type: manifest.BuildValueInt, Default: int64(0)},
"enabled": {Type: manifest.BuildValueBool, Default: false},
}}

values, err := resolveBuildValues(config, BuildOptions{Release: true, Overrides: []BuildOverride{
{Name: "version", Value: "dev"},
{Name: "number", Value: "42"},
{Name: "enabled", Value: "true"},
}})
if err != nil {
t.Fatalf("resolveBuildValues: %v", err)
}
if values["version"].Value != "dev" || values["number"].Value != 42 || values["enabled"].Value != true {
t.Fatalf("values = %#v", values)
}
}

func TestResolveBuildValuesRejectsInvalidOverrides(t *testing.T) {
config := manifest.BuildConfig{Values: map[string]manifest.BuildValue{
"version": {Type: manifest.BuildValueStr, Default: "dev", Release: true},
"number": {Type: manifest.BuildValueInt, Default: int64(0)},
"enabled": {Type: manifest.BuildValueBool, Default: false},
}}
tests := []struct {
name string
options BuildOptions
wantErr string
}{
{name: "missing release value", options: BuildOptions{Release: true}, wantErr: `requires explicit --define values for: "version"`},
{name: "unknown", options: BuildOptions{Overrides: []BuildOverride{{Name: "missing", Value: "x"}}}, wantErr: `unknown build value "missing"`},
{name: "duplicate", options: BuildOptions{Overrides: []BuildOverride{{Name: "version", Value: "a"}, {Name: "version", Value: "b"}}}, wantErr: `defined more than once`},
{name: "bad int", options: BuildOptions{Overrides: []BuildOverride{{Name: "number", Value: "0x2a"}}}, wantErr: `decimal Int`},
{name: "bad bool", options: BuildOptions{Overrides: []BuildOverride{{Name: "enabled", Value: "TRUE"}}}, wantErr: `must be Bool`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := resolveBuildValues(config, tt.options)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
}
})
}
}

func TestBuildModuleImportExposesTypedImmutableValues(t *testing.T) {
root := t.TempDir()
manifestSource := `name = "app"
ard = ">= 0.40.0"

[build.values]
version = { type = "Str", default = "dev" }
number = { type = "Int", default = 7 }
enabled = { type = "Bool", default = true }
`
writeBuildInfoTestFile(t, root, "ard.toml", manifestSource)
source := "use ard/build\nlet version = build::version\nlet number = build::number\nlet enabled = build::enabled\n"
writeBuildInfoTestFile(t, root, "main.ard", source)

module, diagnostics := checkBuildInfoTestModule(t, root, "main.ard", source, BuildOptions{})
if len(diagnostics) != 0 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
imported := module.Program().Imports[BuildModulePath]
if imported == nil {
t.Fatalf("missing build import: %#v", module.Program().Imports)
}
for name, want := range map[string]Type{"version": Str, "number": Int, "enabled": Bool} {
if got := imported.Get(name).Type; got != want {
t.Errorf("%s type = %v, want %v", name, got, want)
}
}
names := make([]string, 0, len(imported.Program().Statements))
for _, statement := range imported.Program().Statements {
definition, ok := statement.Stmt.(*VariableDef)
if !ok || definition.Mutable {
t.Fatalf("build statement = %#v, want immutable variable", statement)
}
names = append(names, definition.Name)
}
if got, want := fmt.Sprint(names), "[enabled number version]"; got != want {
t.Fatalf("build statement order = %s, want %s", got, want)
}
}

func TestBuildModuleImportRequiresDeclarations(t *testing.T) {
root := t.TempDir()
writeBuildInfoTestFile(t, root, "ard.toml", "name = \"app\"\nard = \">= 0.40.0\"\n")
source := "use ard/build\n"
_, diagnostics := checkBuildInfoTestModule(t, root, "main.ard", source, BuildOptions{})
if got := fmt.Sprint(diagnostics); !strings.Contains(got, "requires declarations in [build.values]") {
t.Fatalf("diagnostics = %s", got)
}
}

func TestBuildModuleImportIsRejectedInDependency(t *testing.T) {
workspace := t.TempDir()
root := filepath.Join(workspace, "app")
dependency := filepath.Join(workspace, "dep")
writeBuildInfoTestFile(t, root, "ard.toml", "name = \"app\"\nard = \">= 0.40.0\"\n\n[dependencies]\ndep = { path = \"../dep\" }\n\n[build.values]\nversion = { type = \"Str\", default = \"app\" }\n")
writeBuildInfoTestFile(t, dependency, "ard.toml", "name = \"dep\"\nard = \">= 0.40.0\"\n\n[build.values]\nversion = { type = \"Str\", default = \"dep\" }\n")
writeBuildInfoTestFile(t, dependency, "dep.ard", "use ard/build\nfn version() Str { build::version }\n")
source := "use dep\nlet version = dep::version()\n"
writeBuildInfoTestFile(t, root, "main.ard", source)

_, diagnostics := checkBuildInfoTestModule(t, root, "main.ard", source, BuildOptions{})
if got := fmt.Sprint(diagnostics); !strings.Contains(got, "available only to modules in the root application package") {
t.Fatalf("diagnostics = %s", got)
}
}

func TestBuildModuleDirectDependencyCheckFailsClosed(t *testing.T) {
workspace := t.TempDir()
root := filepath.Join(workspace, "app")
dependency := filepath.Join(workspace, "dep")
writeBuildInfoTestFile(t, root, "ard.toml", "name = \"app\"\nard = \">= 0.40.0\"\n\n[dependencies]\ndep = { path = \"../dep\" }\n\n[build.values]\nversion = { type = \"Str\", default = \"app\" }\n")
writeBuildInfoTestFile(t, dependency, "ard.toml", "name = \"dep\"\nard = \">= 0.40.0\"\n")
source := "use ard/build\n"
dependencyFile := filepath.Join(dependency, "dep.ard")
writeBuildInfoTestFile(t, dependency, "dep.ard", source)
parsed := parse.Parse([]byte(source), dependencyFile)
resolver, err := NewModuleResolver(root)
if err != nil {
t.Fatal(err)
}
checked := New(dependencyFile, parsed.Program, resolver)
checked.Check()
if got := fmt.Sprint(checked.Diagnostics()); !strings.Contains(got, "available only to modules in the root application package") {
t.Fatalf("diagnostics = %s", got)
}
}

func checkBuildInfoTestModule(t *testing.T, root, rel, source string, options BuildOptions) (Module, []Diagnostic) {
t.Helper()
parsed := parse.Parse([]byte(source), filepath.Join(root, rel))
if len(parsed.Errors) != 0 {
t.Fatalf("parse errors = %#v", parsed.Errors)
}
resolver, err := NewModuleResolverWithOptions(root, options)
if err != nil {
t.Fatalf("NewModuleResolverWithOptions: %v", err)
}
checker := New(rel, parsed.Program, resolver)
checker.Check()
return checker.Module(), checker.Diagnostics()
}

func writeBuildInfoTestFile(t *testing.T, root, rel, content string) {
t.Helper()
path := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
13 changes: 12 additions & 1 deletion compiler/checker/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,18 @@ func (c *Checker) Check() {
continue
}

if strings.HasPrefix(imp.Path, "ard/") {
if imp.Path == BuildModulePath {
if c.moduleResolver == nil {
c.addUnresolvedReference(unknownModule, imp.Path, imp.GetLocation())
continue
}
mod, err := c.moduleResolver.resolveBuildModule(c.modulePath, c.filePath)
if err != nil {
c.addDiagnostic(ardImportResolutionDiagnostic{Path: imp.Path, Cause: err.Error(), Span: c.sourceSpan(imp.PathLocation)}.build())
continue
}
c.program.Imports[imp.Name] = mod
} else if strings.HasPrefix(imp.Path, "ard/") {
// Handle standard library imports
if mod, ok := findInStdLib(imp.Path); ok {
c.program.Imports[imp.Name] = mod
Expand Down
Loading
Loading