diff --git a/compiler/checker/build.go b/compiler/checker/build.go new file mode 100644 index 00000000..15e68f68 --- /dev/null +++ b/compiler/checker/build.go @@ -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 +} diff --git a/compiler/checker/build_test.go b/compiler/checker/build_test.go new file mode 100644 index 00000000..9e9ebe35 --- /dev/null +++ b/compiler/checker/build_test.go @@ -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) + } +} diff --git a/compiler/checker/checker.go b/compiler/checker/checker.go index ca6a12d5..bedb0024 100644 --- a/compiler/checker/checker.go +++ b/compiler/checker/checker.go @@ -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 diff --git a/compiler/checker/module_resolver.go b/compiler/checker/module_resolver.go index 88b1ffb0..31198420 100644 --- a/compiler/checker/module_resolver.go +++ b/compiler/checker/module_resolver.go @@ -18,6 +18,7 @@ import ( "slices" + "github.com/akonwi/ard/manifest" "github.com/akonwi/ard/parse" "github.com/akonwi/ard/version" ) @@ -28,6 +29,7 @@ type ProjectInfo struct { ProjectName string // project name from ard.toml or directory name Dependencies map[string]DependencyInfo // dependency aliases from ard.toml Go GoProjectConfig + Build manifest.BuildConfig RootPackageID string Packages map[string]PackageInfo } @@ -86,6 +88,7 @@ type ModuleResolver struct { overlays map[string]string // unsaved source text by resolved file path loadingChain []string // track canonical module paths currently being loaded for circular dependency detection modulePackages map[string]string // canonical module path -> package ID + buildModule Module } type ResolvedImport struct { @@ -114,29 +117,23 @@ func FindProjectRoot(startPath string) (*ProjectInfo, error) { if err != nil { return nil, err } - // Found ard.toml, parse project name - projectName, err := parseProjectName(tomlPath) + document, err := manifest.ParseFile(tomlPath) if err != nil { return nil, fmt.Errorf("failed to parse ard.toml: %w", err) } - - // Check ard version constraint (required in ard.toml) - constraint, ok := parseArdVersion(tomlPath) - if !ok { + projectName := document.Name + if projectName == "" { + return nil, fmt.Errorf("failed to parse ard.toml: no project name found in ard.toml") + } + if document.Ard == "" { return nil, fmt.Errorf("ard.toml is missing required field: ard (e.g. ard = \">= 0.13.0\")") } - if err := version.CheckVersion(constraint); err != nil { + if err := version.CheckVersion(document.Ard); err != nil { return nil, err } - dependencies, err := parseProjectDependencies(tomlPath, current) - if err != nil { - return nil, fmt.Errorf("failed to parse ard.toml: %w", err) - } - goConfig, err := parseGoProjectConfig(tomlPath) - if err != nil { - return nil, fmt.Errorf("failed to parse ard.toml: %w", err) - } + dependencies := projectDependencies(document.Dependencies, current) + goConfig := GoProjectConfig{BuildTags: append([]string(nil), document.Go.BuildTags...)} rootPackageID := "root" packages := map[string]PackageInfo{ rootPackageID: { @@ -159,6 +156,7 @@ func FindProjectRoot(startPath string) (*ProjectInfo, error) { ProjectName: projectName, Dependencies: dependencies, Go: goConfig, + Build: document.Build, RootPackageID: rootPackageID, Packages: packages, }, nil @@ -215,144 +213,47 @@ func validatePackageRoot(rootPath string) (string, error) { return absPath, nil } -// parseProjectName extracts the project name from ard.toml -// For now, use simple regex parsing. Format: name = "project_name" func parseProjectName(tomlPath string) (string, error) { - content, err := os.ReadFile(tomlPath) + document, err := manifest.ParseFile(tomlPath) if err != nil { return "", err } - - // Simple regex to match: name = "project_name" or name = 'project_name' - re := regexp.MustCompile(`(?m)^\s*name\s*=\s*["']([^"']+)["']`) - matches := re.FindStringSubmatch(string(content)) - if len(matches) < 2 { + if document.Name == "" { return "", fmt.Errorf("no project name found in ard.toml") } - - return matches[1], nil -} - -// parseArdVersion extracts the ard constraint from ard.toml if present. -// Format: ard = ">= 0.13.0" or ard = "0.13.0" -func parseArdVersion(tomlPath string) (string, bool) { - content, err := os.ReadFile(tomlPath) - if err != nil { - return "", false - } - - re := regexp.MustCompile(`(?m)^\s*ard\s*=\s*["']([^"']+)["']`) - matches := re.FindStringSubmatch(string(content)) - if len(matches) < 2 { - return "", false - } - - return matches[1], true -} - -func parseGoProjectConfig(tomlPath string) (GoProjectConfig, error) { - content, err := os.ReadFile(tomlPath) - if err != nil { - return GoProjectConfig{}, err - } - config := GoProjectConfig{} - section := "" - sectionRe := regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`) - buildTagsAssignRe := regexp.MustCompile(`^\s*build_tags\s*=`) - buildTagsRe := regexp.MustCompile(`^\s*build_tags\s*=\s*\[(.*)\]\s*(?:#.*)?$`) - quotedTagRe := regexp.MustCompile(`["']([^"']*)["']`) - validTagRe := regexp.MustCompile(`^[A-Za-z0-9_.]+$`) - for _, line := range strings.Split(string(content), "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - if matches := sectionRe.FindStringSubmatch(line); len(matches) == 2 { - section = matches[1] - continue - } - if section != "go" { - continue - } - if !buildTagsAssignRe.MatchString(line) { - continue - } - matches := buildTagsRe.FindStringSubmatch(line) - if len(matches) != 2 { - return GoProjectConfig{}, fmt.Errorf("[go].build_tags must be a list of quoted strings") - } - rawList := strings.TrimSpace(matches[1]) - if rawList == "" { - continue - } - rawItems := strings.Split(rawList, ",") - for i, rawItem := range rawItems { - rawItem = strings.TrimSpace(rawItem) - if rawItem == "" { - if i == len(rawItems)-1 && strings.HasSuffix(strings.TrimSpace(rawList), ",") { - continue - } - return GoProjectConfig{}, fmt.Errorf("[go].build_tags must be a list of quoted strings") - } - tagMatch := quotedTagRe.FindStringSubmatch(rawItem) - if len(tagMatch) != 2 || tagMatch[0] != rawItem { - return GoProjectConfig{}, fmt.Errorf("[go].build_tags must be a list of quoted strings") - } - tag := tagMatch[1] - if tag == "" || !validTagRe.MatchString(tag) { - return GoProjectConfig{}, fmt.Errorf("invalid Go build tag %q", tag) - } - config.BuildTags = append(config.BuildTags, tag) - } - } - return config, nil + return document.Name, nil } func parseProjectDependencies(tomlPath string, projectRoot string) (map[string]DependencyInfo, error) { - content, err := os.ReadFile(tomlPath) + document, err := manifest.ParseFile(tomlPath) if err != nil { return nil, err } - deps := map[string]DependencyInfo{} - inDependencies := false - depRe := regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*\{([^}]*)\}`) - pathRe := regexp.MustCompile(`\bpath\s*=\s*["']([^"']+)["']`) - gitRe := regexp.MustCompile(`\bgit\s*=\s*["']([^"']+)["']`) - tagRe := regexp.MustCompile(`\btag\s*=\s*["']([^"']+)["']`) - commitRe := regexp.MustCompile(`\bcommit\s*=\s*["']([^"']+)["']`) - for _, line := range strings.Split(string(content), "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - inDependencies = trimmed == "[dependencies]" - continue - } - if !inDependencies || trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - matches := depRe.FindStringSubmatch(line) - if len(matches) < 3 { - continue + return projectDependencies(document.Dependencies, projectRoot), nil +} + +func projectDependencies(declarations map[string]manifest.Dependency, projectRoot string) map[string]DependencyInfo { + deps := make(map[string]DependencyInfo, len(declarations)) + for alias, declaration := range declarations { + dep := DependencyInfo{ + Alias: alias, + Name: alias, + Git: declaration.Git, + Tag: declaration.Tag, + Commit: declaration.Commit, } - alias := matches[1] - body := matches[2] - dep := DependencyInfo{Alias: alias, Name: alias} - if pathMatches := pathRe.FindStringSubmatch(body); len(pathMatches) >= 2 { - dep.SourcePath = pathMatches[1] + if declaration.Path != "" { + dep.SourcePath = declaration.Path if !filepath.IsAbs(dep.SourcePath) { dep.SourcePath = filepath.Clean(filepath.Join(projectRoot, dep.SourcePath)) } dep.RootPath = dep.SourcePath dep.PackageID = "path:" + dep.SourcePath } - if gitMatches := gitRe.FindStringSubmatch(body); len(gitMatches) >= 2 { - dep.Git = gitMatches[1] - } - if tagMatches := tagRe.FindStringSubmatch(body); len(tagMatches) >= 2 { - dep.Tag = tagMatches[1] + if dep.Tag != "" { dep.Requested = dep.Tag } - if commitMatches := commitRe.FindStringSubmatch(body); len(commitMatches) >= 2 { - dep.Commit = commitMatches[1] + if dep.Commit != "" { dep.Requested = dep.Commit } if dep.SourcePath == "" && dep.Git == "" { @@ -360,7 +261,7 @@ func parseProjectDependencies(tomlPath string, projectRoot string) (map[string]D } deps[alias] = dep } - return deps, nil + return deps } func ReadDependencyLock(projectRoot string) (LockFile, bool, error) { @@ -1256,12 +1157,22 @@ func PruneLockDependency(projectRoot string, alias string) error { return WriteDependencyLock(projectRoot, lock) } -// NewModuleResolver creates a new module resolver for the given working directory +// NewModuleResolver creates a resolver using manifest defaults for build values. func NewModuleResolver(workingDir string) (*ModuleResolver, error) { + return NewModuleResolverWithOptions(workingDir, BuildOptions{}) +} + +// NewModuleResolverWithOptions creates a resolver with invocation-specific +// build value overrides and release policy. +func NewModuleResolverWithOptions(workingDir string, options BuildOptions) (*ModuleResolver, error) { project, err := FindProjectRoot(workingDir) if err != nil { return nil, err } + values, err := resolveBuildValues(project.Build, options) + if err != nil { + return nil, err + } return &ModuleResolver{ project: project, @@ -1270,9 +1181,56 @@ func NewModuleResolver(workingDir string) (*ModuleResolver, error) { overlays: make(map[string]string), loadingChain: make([]string, 0), modulePackages: make(map[string]string), + buildModule: newBuildModule(values), }, nil } +// IsRootPackageModule reports whether a checked module belongs to the +// application package whose manifest supplied the build values. Unknown files +// fail closed instead of inheriting packageIDForModule's root fallback. +func (mr *ModuleResolver) IsRootPackageModule(modulePath string, filePath string) bool { + if mr == nil || mr.project == nil { + return false + } + if packageID, ok := mr.modulePackages[modulePath]; ok { + return packageID == mr.project.RootPackageID + } + absolutePath := filePath + if !filepath.IsAbs(absolutePath) { + absolutePath = filepath.Join(mr.project.RootPath, absolutePath) + } + absolutePath = filepath.Clean(absolutePath) + for packageID, pkg := range mr.project.Packages { + if packageID == mr.project.RootPackageID || pkg.RootPath == "" { + continue + } + if pathWithinRoot(absolutePath, pkg.RootPath) { + return false + } + } + root, ok := mr.project.Packages[mr.project.RootPackageID] + return ok && pathWithinRoot(absolutePath, root.RootPath) +} + +func pathWithinRoot(filePath string, rootPath string) bool { + absoluteRoot, err := filepath.Abs(rootPath) + if err != nil { + return false + } + relative, err := filepath.Rel(absoluteRoot, filePath) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) +} + +func (mr *ModuleResolver) resolveBuildModule(importerModulePath string, filePath string) (Module, error) { + if !mr.IsRootPackageModule(importerModulePath, filePath) { + return nil, fmt.Errorf("%s is available only to modules in the root application package", BuildModulePath) + } + if mr.buildModule == nil || len(mr.project.Build.Values) == 0 { + return nil, fmt.Errorf("%s requires declarations in [build.values] in the root ard.toml", BuildModulePath) + } + return mr.buildModule, nil +} + // SetOverlay provides unsaved source text for a resolved module file path. // The LSP uses this so imported open documents are checked from the editor // buffer instead of stale on-disk contents. diff --git a/compiler/checker/module_resolver_test.go b/compiler/checker/module_resolver_test.go index ba0fb1c8..2ec74c96 100644 --- a/compiler/checker/module_resolver_test.go +++ b/compiler/checker/module_resolver_test.go @@ -152,7 +152,11 @@ build_tags = ["sqlite", "debug", "sqlite",] if err == nil { t.Fatalf("expected error for assignment %s", assignment) } - if !strings.Contains(err.Error(), "[go].build_tags must be a list of quoted strings") { + want := "invalid TOML" + if assignment == `build_tags = "sqlite"` { + want = "[go].build_tags must be a list of quoted strings" + } + if !strings.Contains(err.Error(), want) { t.Fatalf("unexpected error for %s: %v", assignment, err) } } diff --git a/compiler/frontend/load.go b/compiler/frontend/load.go index 3082da04..9165e0c7 100644 --- a/compiler/frontend/load.go +++ b/compiler/frontend/load.go @@ -15,7 +15,15 @@ type LoadResult struct { ProjectInfo *checker.ProjectInfo } +type LoadOptions struct { + Build checker.BuildOptions +} + func LoadModule(inputPath string) (*LoadResult, error) { + return LoadModuleWithOptions(inputPath, LoadOptions{}) +} + +func LoadModuleWithOptions(inputPath string, options LoadOptions) (*LoadResult, error) { sourceCode, err := os.ReadFile(inputPath) if err != nil { return nil, fmt.Errorf("error reading file %s - %v", inputPath, err) @@ -31,7 +39,7 @@ func LoadModule(inputPath string) (*LoadResult, error) { program := result.Program workingDir := filepath.Dir(inputPath) - moduleResolver, err := checker.NewModuleResolver(workingDir) + moduleResolver, err := checker.NewModuleResolverWithOptions(workingDir, options.Build) if err != nil { return nil, fmt.Errorf("error initializing module resolver: %w", err) } diff --git a/compiler/go.mod b/compiler/go.mod index eb8f5083..e238a657 100644 --- a/compiler/go.mod +++ b/compiler/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-sql-driver/mysql v1.9.3 github.com/jackc/pgx/v5 v5.8.0 github.com/mattn/go-sqlite3 v1.14.28 + github.com/pelletier/go-toml/v2 v2.2.4 go.lsp.dev/jsonrpc2 v0.10.0 go.lsp.dev/protocol v0.12.0 go.lsp.dev/uri v0.3.0 diff --git a/compiler/go.sum b/compiler/go.sum index 84552ec5..4e6b926b 100644 --- a/compiler/go.sum +++ b/compiler/go.sum @@ -23,6 +23,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/compiler/lsp/completion_spans.go b/compiler/lsp/completion_spans.go index f26cefa2..b0972a1a 100644 --- a/compiler/lsp/completion_spans.go +++ b/compiler/lsp/completion_spans.go @@ -434,11 +434,15 @@ func isTypeNameSymbol(sym *checker.Symbol) bool { // computeImportCompletions serves `use ` path completion, which is // filesystem/parse based rather than semantic. func computeImportCompletions(source string, filePath string, position protocol.Position) []protocol.CompletionItem { + return computeImportCompletionsWithRoot(source, filePath, "", position) +} + +func computeImportCompletionsWithRoot(source string, filePath string, projectRoot string, position protocol.Position) []protocol.CompletionItem { cctx, ok := completionContextAt(source, position) if !ok || cctx.kind != completionImport { return []protocol.CompletionItem{} } - return withCompletionTextEdits(importPathCompletionItems(cctx.importPath, filePath), cctx, position) + return withCompletionTextEdits(importPathCompletionItemsWithRoot(cctx.importPath, filePath, projectRoot), cctx, position) } // mergedStructMethods returns a struct's methods across the local program diff --git a/compiler/lsp/import_completion.go b/compiler/lsp/import_completion.go index 2c9170fe..b2cb5736 100644 --- a/compiler/lsp/import_completion.go +++ b/compiler/lsp/import_completion.go @@ -23,7 +23,14 @@ func importCompletionPrefix(linePrefix string) (string, bool) { } func importPathCompletionItems(pathPrefix string, filePath string) []protocol.CompletionItem { - workingDir := filepath.Dir(filePath) + return importPathCompletionItemsWithRoot(pathPrefix, filePath, "") +} + +func importPathCompletionItemsWithRoot(pathPrefix string, filePath string, projectRoot string) []protocol.CompletionItem { + workingDir := projectRoot + if workingDir == "" { + workingDir = filepath.Dir(filePath) + } resolver, err := checker.NewModuleResolver(workingDir) if err != nil { return nil @@ -58,6 +65,9 @@ func importPathCompletionItems(pathPrefix string, filePath string) []protocol.Co for _, entry := range listArdStdlibImportChildren("") { add(entry) } + if len(project.Build.Values) > 0 && resolver.IsRootPackageModule("", filePath) { + add("build") + } } else if strings.HasPrefix(base, "ard/") { for _, entry := range listArdStdlibImportChildren(strings.TrimPrefix(base, "ard/")) { add(entry) diff --git a/compiler/lsp/import_completion_test.go b/compiler/lsp/import_completion_test.go new file mode 100644 index 00000000..f0da198a --- /dev/null +++ b/compiler/lsp/import_completion_test.go @@ -0,0 +1,62 @@ +package lsp + +import ( + "os" + "path/filepath" + "testing" +) + +func TestImportCompletionIncludesDeclaredBuildModule(t *testing.T) { + root := t.TempDir() + manifest := "name = \"app\"\nard = \">= 0.40.0\"\n\n[build.values]\nversion = { type = \"Str\", default = \"dev\" }\n" + if err := os.WriteFile(filepath.Join(root, "ard.toml"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + file := filepath.Join(root, "main.ard") + if err := os.WriteFile(file, nil, 0o644); err != nil { + t.Fatal(err) + } + items := importPathCompletionItems("ard/b", file) + if len(items) != 1 || items[0].Label != "build" { + t.Fatalf("items = %#v, want build", items) + } +} + +func TestImportCompletionOmitsBuildModuleForDependencyFile(t *testing.T) { + workspace := t.TempDir() + root := filepath.Join(workspace, "app") + dependency := filepath.Join(workspace, "dep") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dependency, 0o755); err != nil { + t.Fatal(err) + } + rootManifest := "name = \"app\"\nard = \">= 0.40.0\"\n\n[dependencies]\ndep = { path = \"../dep\" }\n\n[build.values]\nversion = { type = \"Str\", default = \"app\" }\n" + dependencyManifest := "name = \"dep\"\nard = \">= 0.40.0\"\n\n[build.values]\nversion = { type = \"Str\", default = \"dep\" }\n" + if err := os.WriteFile(filepath.Join(root, "ard.toml"), []byte(rootManifest), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dependency, "ard.toml"), []byte(dependencyManifest), 0o644); err != nil { + t.Fatal(err) + } + items := importPathCompletionItemsWithRoot("ard/b", filepath.Join(dependency, "dep.ard"), root) + for _, item := range items { + if item.Label == "build" { + t.Fatalf("items unexpectedly include build: %#v", items) + } + } +} + +func TestImportCompletionOmitsUndeclaredBuildModule(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "ard.toml"), []byte("name = \"app\"\nard = \">= 0.40.0\"\n"), 0o644); err != nil { + t.Fatal(err) + } + items := importPathCompletionItems("ard/b", filepath.Join(root, "main.ard")) + for _, item := range items { + if item.Label == "build" { + t.Fatalf("items unexpectedly include build: %#v", items) + } + } +} diff --git a/compiler/lsp/server.go b/compiler/lsp/server.go index 18f35654..f59ea766 100644 --- a/compiler/lsp/server.go +++ b/compiler/lsp/server.go @@ -671,7 +671,8 @@ func (s *Server) handleCompletion(ctx context.Context, reply jsonrpc2.Replier, r if len(items) == 0 { // Import-path completion is parse/filesystem based and stays on // its own dedicated path. - items = computeImportCompletions(doc.Text, filePath, params.Position) + projectRoot := s.workspaceFor(filePath).Engine().ProjectRoot() + items = computeImportCompletionsWithRoot(doc.Text, filePath, projectRoot, params.Position) } }() if items == nil { diff --git a/compiler/main.go b/compiler/main.go index 15fc6266..3c168d1f 100644 --- a/compiler/main.go +++ b/compiler/main.go @@ -11,6 +11,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "time" @@ -21,6 +22,7 @@ import ( "github.com/akonwi/ard/frontend" gotarget "github.com/akonwi/ard/go" "github.com/akonwi/ard/lsp" + "github.com/akonwi/ard/manifest" "github.com/akonwi/ard/parse" "github.com/akonwi/ard/version" ) @@ -94,12 +96,12 @@ func main() { } case "build": { - inputPath, outputPath, err := parseBuildArgs(os.Args[2:]) + args, err := parseBuildArgs(os.Args[2:]) if err != nil { reportCLIError(os.Stderr, err) os.Exit(1) } - if _, err := buildGoBinary(inputPath, outputPath); err != nil { + if _, err := buildGoBinaryWithOptions(args.Input, args.Output, checker.BuildOptions{Release: args.Release, Overrides: args.Overrides}); err != nil { reportCLIError(os.Stderr, err) os.Exit(1) } @@ -273,7 +275,8 @@ func printUsage(w io.Writer) { Commands: check Type-check a program run Run a program - build [--out ] Build a program + build [--out ] [--release] [--define ] + Build a program test [path] [--filter ] Run Ard tests add [as alias] Add or update a Git dependency and lock it update [alias...] Update Git dependencies to their latest commit @@ -335,34 +338,14 @@ func runAddCommand(args []string) error { } func dependencyAliasesForGitInManifest(path string, git string, keepAlias string) ([]string, error) { - data, err := os.ReadFile(path) + document, err := manifest.ParseFile(path) if err != nil { return nil, err } git = checker.CanonicalGitSource(git) aliases := []string{} - inDependencies := false - depRe := regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*\{([^}]*)\}`) - gitRe := regexp.MustCompile(`\bgit\s*=\s*["']([^"']+)["']`) - for _, line := range strings.Split(string(data), "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - inDependencies = trimmed == "[dependencies]" - continue - } - if !inDependencies { - continue - } - matches := depRe.FindStringSubmatch(line) - if len(matches) < 3 { - continue - } - alias := matches[1] - if alias == keepAlias { - continue - } - gitMatches := gitRe.FindStringSubmatch(matches[2]) - if len(gitMatches) >= 2 && checker.CanonicalGitSource(gitMatches[1]) == git { + for alias, dependency := range document.Dependencies { + if alias != keepAlias && checker.CanonicalGitSource(dependency.Git) == git { aliases = append(aliases, alias) } } @@ -691,16 +674,11 @@ func cloneDependencyForManifest(dep checker.DependencyInfo) (string, func(), err } func parseManifestName(path string) (string, bool) { - content, err := os.ReadFile(path) - if err != nil { - return "", false - } - re := regexp.MustCompile(`(?m)^\s*name\s*=\s*["']([^"']+)["']`) - matches := re.FindStringSubmatch(string(content)) - if len(matches) < 2 { + document, err := manifest.ParseFile(path) + if err != nil || document.Name == "" { return "", false } - return matches[1], true + return document.Name, true } func isGitCommitish(ref string) bool { @@ -751,6 +729,9 @@ func replaceDependencyInManifest(path string, removeAliases []string, dep checke for _, alias := range removeAliases { remove[alias] = true } + if err := requireEditableDependencyDeclarations(data, lines, remove); err != nil { + return err + } if start < 0 { text := strings.TrimRight(string(data), "\n") + "\n\n[dependencies]\n" + entry + "\n" return os.WriteFile(path, []byte(text), 0o644) @@ -778,13 +759,57 @@ func replaceDependencyInManifest(path string, removeAliases []string, dep checke } func manifestLineMatchesAnyAlias(line string, aliases map[string]bool) bool { + alias, ok := editableDependencyLineAlias(line) + return ok && aliases[alias] +} + +func requireEditableDependencyDeclarations(data []byte, lines []string, aliases map[string]bool) error { + document, err := manifest.Parse(data) + if err != nil { + return err + } + found := map[string]bool{} + inDependencies := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + inDependencies = trimmed == "[dependencies]" + continue + } + if inDependencies { + if alias, ok := editableDependencyLineAlias(line); ok { + found[alias] = true + } + } + } for alias := range aliases { - aliasRe := regexp.MustCompile("^\\s*" + regexp.QuoteMeta(alias) + "\\s*=") - if aliasRe.MatchString(line) { - return true + if _, exists := document.Dependencies[alias]; exists && !found[alias] { + return fmt.Errorf("dependency %q uses a TOML form that cannot be edited safely; rewrite it as a one-line inline table", alias) } } - return false + return nil +} + +func editableDependencyLineAlias(line string) (string, bool) { + trimmed := strings.TrimSpace(line) + equals := strings.IndexByte(trimmed, '=') + if equals < 1 { + return "", false + } + key := strings.TrimSpace(trimmed[:equals]) + value := strings.TrimSpace(trimmed[equals+1:]) + if !strings.HasPrefix(value, "{") || !strings.Contains(value, "}") { + return "", false + } + if len(key) >= 2 && key[0] == '\'' && key[len(key)-1] == '\'' { + return key[1 : len(key)-1], true + } + if len(key) >= 2 && key[0] == '"' && key[len(key)-1] == '"' { + unquoted, err := strconv.Unquote(key) + return unquoted, err == nil + } + matched, _ := regexp.MatchString(`^[A-Za-z_][A-Za-z0-9_-]*$`, key) + return key, matched } func removeDependencyFromManifest(path string, alias string) (bool, error) { @@ -808,14 +833,16 @@ func removeDependencyFromManifest(path string, alias string) (bool, error) { } } } + if err := requireEditableDependencyDeclarations(data, lines, map[string]bool{alias: true}); err != nil { + return false, err + } if start < 0 { return false, nil } - aliasRe := regexp.MustCompile("^\\s*" + regexp.QuoteMeta(alias) + "\\s*=") removed := false updated := make([]string, 0, len(lines)) for i, line := range lines { - if i > start && i < end && aliasRe.MatchString(line) { + if i > start && i < end && manifestLineMatchesAnyAlias(line, map[string]bool{alias: true}) { removed = true continue } @@ -863,38 +890,68 @@ func parseRunArgs(args []string) (string, error) { return inputPath, nil } -func parseBuildArgs(args []string) (string, string, error) { - inputPath := "" - outputPath := "" +type buildArgs struct { + Input string + Output string + Release bool + Overrides []checker.BuildOverride +} + +func parseBuildArgs(args []string) (buildArgs, error) { + result := buildArgs{} + seenOutput := false + seenRelease := false for i := 0; i < len(args); i++ { arg := args[i] - if arg == "--out" { + switch arg { + case "--out": + if seenOutput { + return buildArgs{}, fmt.Errorf("--out may be provided only once") + } + if i+1 >= len(args) || args[i+1] == "" || strings.HasPrefix(args[i+1], "-") { + return buildArgs{}, fmt.Errorf("--out requires a path") + } + seenOutput = true + result.Output = args[i+1] + i++ + case "--release": + if seenRelease { + return buildArgs{}, fmt.Errorf("--release may be provided only once") + } + seenRelease = true + result.Release = true + case "--define": if i+1 >= len(args) { - return "", "", fmt.Errorf("--out requires a path") + return buildArgs{}, fmt.Errorf("--define requires name=value") } - outputPath = args[i+1] + definition := args[i+1] + name, value, ok := strings.Cut(definition, "=") + if !ok || name == "" { + return buildArgs{}, fmt.Errorf("--define requires name=value") + } + result.Overrides = append(result.Overrides, checker.BuildOverride{Name: name, Value: value}) i++ - continue - } - if strings.HasPrefix(arg, "-") { - return "", "", fmt.Errorf("unknown flag: %s", arg) - } - if inputPath == "" { - inputPath = arg - continue + default: + if strings.HasPrefix(arg, "-") { + return buildArgs{}, fmt.Errorf("unknown flag: %s", arg) + } + if result.Input == "" { + result.Input = arg + continue + } + return buildArgs{}, fmt.Errorf("unexpected argument: %s", arg) } - return "", "", fmt.Errorf("unexpected argument: %s", arg) } - if inputPath == "" { - return "", "", fmt.Errorf("expected filepath argument") + if result.Input == "" { + return buildArgs{}, fmt.Errorf("expected filepath argument") } - if outputPath == "" { - outputPath = filepath.Base(strings.TrimSuffix(inputPath, filepath.Ext(inputPath))) - if outputPath == "" || outputPath == "." || outputPath == string(filepath.Separator) { - outputPath = "main" + if result.Output == "" { + result.Output = filepath.Base(strings.TrimSuffix(result.Input, filepath.Ext(result.Input))) + if result.Output == "" || result.Output == "." || result.Output == string(filepath.Separator) { + result.Output = "main" } } - return inputPath, outputPath, nil + return result, nil } func parseFormatArgs(args []string) (string, bool, error) { @@ -1477,12 +1534,16 @@ func reportTestSummary(outcomes []testOutcome) { } func buildGoBinary(inputPath string, outputPath string) (string, error) { + return buildGoBinaryWithOptions(inputPath, outputPath, checker.BuildOptions{}) +} + +func buildGoBinaryWithOptions(inputPath string, outputPath string, buildOptions checker.BuildOptions) (string, error) { profile := newPipelineProfile("build go") defer profile.Print() var loaded *frontend.LoadResult if err := profile.Time("frontend.load_module", func() error { var loadErr error - loaded, loadErr = frontend.LoadModule(inputPath) + loaded, loadErr = frontend.LoadModuleWithOptions(inputPath, frontend.LoadOptions{Build: buildOptions}) return loadErr }); err != nil { return "", err diff --git a/compiler/main_test.go b/compiler/main_test.go index 9cd08007..6b5b0c8c 100644 --- a/compiler/main_test.go +++ b/compiler/main_test.go @@ -912,6 +912,51 @@ func TestBuildGoBinary(t *testing.T) { t.Fatalf("stat built binary: %v", err) } } +func TestBuildGoBinaryEmbedsBuildValueOverrides(t *testing.T) { + tempDir := t.TempDir() + manifest := `name = "buildmeta" +ard = ">= 0.40.0" + +[build.values] +version = { type = "Str", default = "dev", release = true } +build_number = { type = "Int", default = 0 } +experimental = { type = "Bool", default = false } +` + if err := os.WriteFile(filepath.Join(tempDir, "ard.toml"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + sourcePath := filepath.Join(tempDir, "main.ard") + source := `use ard/build +use go:fmt + +fn main() { + fmt::Printf("%s:%d:%t", build::version, build::build_number, build::experimental) +} +` + if err := os.WriteFile(sourcePath, []byte(source), 0o644); err != nil { + t.Fatal(err) + } + outputPath := filepath.Join(tempDir, "buildmeta") + _, err := buildGoBinaryWithOptions(sourcePath, outputPath, checker.BuildOptions{ + Release: true, + Overrides: []checker.BuildOverride{ + {Name: "version", Value: "v1=final"}, + {Name: "build_number", Value: "42"}, + {Name: "experimental", Value: "true"}, + }, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + output, err := exec.Command(outputPath).CombinedOutput() + if err != nil { + t.Fatalf("run: %v: %s", err, output) + } + if got, want := string(output), "v1=final:42:true"; got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + func TestParseTestArgs(t *testing.T) { tests := []struct { name string @@ -990,44 +1035,30 @@ func TestParseBuildArgs(t *testing.T) { args []string path string out string + release bool + overrides []checker.BuildOverride expectErr bool errMessage string }{ + {name: "input only", args: []string{"demo.ard"}, path: "demo.ard", out: "demo"}, + {name: "nested input defaults to file basename", args: []string{"samples/main.ard"}, path: "samples/main.ard", out: "main"}, + {name: "explicit output", args: []string{"samples/main.ard", "--out", "demo"}, path: "samples/main.ard", out: "demo"}, { - name: "input only", - args: []string{"demo.ard"}, - path: "demo.ard", - out: "demo", - }, - { - name: "nested input defaults to file basename", - args: []string{"samples/main.ard"}, - path: "samples/main.ard", - out: "main", - }, - { - name: "explicit output", - args: []string{"samples/main.ard", "--out", "demo"}, - path: "samples/main.ard", - out: "demo", - }, - { - name: "removed target flag", - args: []string{"samples/main.ard", "--target", "go"}, - expectErr: true, - errMessage: "unknown flag: --target", - }, - { - name: "unknown flag", - args: []string{"samples/main.ard", "--wat"}, - expectErr: true, - errMessage: "unknown flag: --wat", + name: "release with definitions", + args: []string{"samples/main.ard", "--release", "--define", "version=v1=final", "--define", "channel="}, + path: "samples/main.ard", + out: "main", + release: true, + overrides: []checker.BuildOverride{{Name: "version", Value: "v1=final"}, {Name: "channel", Value: ""}}, }, + {name: "missing output before flag", args: []string{"samples/main.ard", "--out", "--release"}, expectErr: true, errMessage: "--out requires a path"}, + {name: "removed target flag", args: []string{"samples/main.ard", "--target", "go"}, expectErr: true, errMessage: "unknown flag: --target"}, + {name: "unknown flag", args: []string{"samples/main.ard", "--wat"}, expectErr: true, errMessage: "unknown flag: --wat"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path, out, err := parseBuildArgs(tt.args) + got, err := parseBuildArgs(tt.args) if tt.expectErr { if err == nil { t.Fatalf("expected error %q, got nil", tt.errMessage) @@ -1037,15 +1068,14 @@ func TestParseBuildArgs(t *testing.T) { } return } - if err != nil { t.Fatalf("did not expect error: %v", err) } - if path != tt.path { - t.Fatalf("expected path %q, got %q", tt.path, path) + if got.Input != tt.path || got.Output != tt.out { + t.Fatalf("paths = input %q output %q, want input %q output %q", got.Input, got.Output, tt.path, tt.out) } - if out != tt.out { - t.Fatalf("expected output %q, got %q", tt.out, out) + if got.Release != tt.release || fmt.Sprint(got.Overrides) != fmt.Sprint(tt.overrides) { + t.Fatalf("build options = %#v, want release=%t overrides=%#v", got, tt.release, tt.overrides) } }) } @@ -1598,6 +1628,23 @@ func TestReplaceDependencyInManifestRemovesOldAlias(t *testing.T) { t.Fatalf("manifest missing replacement or existing dependency:\n%s", got) } } +func TestReplaceDependencyInManifestRejectsMultilineDeclaration(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "ard.toml") + input := "name = \"demo\"\nard = \">= 0.1.0\"\n\n[dependencies.vaxis]\ngit = \"https://github.com/akonwi/vaxis-ard.git\"\ncommit = \"old\"\n" + if err := os.WriteFile(manifestPath, []byte(input), 0o644); err != nil { + t.Fatal(err) + } + dep := checker.DependencyInfo{Alias: "vaxis", Git: "https://github.com/akonwi/vaxis-ard.git", Commit: "new"} + err := replaceDependencyInManifest(manifestPath, nil, dep) + if err == nil || !strings.Contains(err.Error(), "cannot be edited safely") { + t.Fatalf("error = %v, want safe-edit diagnostic", err) + } + if data, readErr := os.ReadFile(manifestPath); readErr != nil || string(data) != input { + t.Fatalf("manifest changed after rejection: %v\n%s", readErr, data) + } +} + func TestDependencyAliasesForGitInManifestCanonicalizesRawEntries(t *testing.T) { dir := t.TempDir() manifest := filepath.Join(dir, "ard.toml") @@ -1649,6 +1696,19 @@ func TestRemoveDependencyFromManifest(t *testing.T) { t.Fatalf("manifest lost remaining dependencies:\n%s", got) } } +func TestRemoveDependencyFromManifestRejectsNestedTable(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "ard.toml") + input := "name = \"demo\"\nard = \">= 0.1.0\"\n\n[dependencies.vaxis]\ngit = \"https://github.com/akonwi/vaxis-ard.git\"\ncommit = \"old\"\n" + if err := os.WriteFile(manifestPath, []byte(input), 0o644); err != nil { + t.Fatal(err) + } + removed, err := removeDependencyFromManifest(manifestPath, "vaxis") + if err == nil || !strings.Contains(err.Error(), "cannot be edited safely") || removed { + t.Fatalf("removed = %t, error = %v, want safe-edit rejection", removed, err) + } +} + func TestRemoveDependencyFromManifestMissing(t *testing.T) { dir := t.TempDir() manifest := filepath.Join(dir, "ard.toml") diff --git a/compiler/manifest/manifest.go b/compiler/manifest/manifest.go new file mode 100644 index 00000000..15a837ac --- /dev/null +++ b/compiler/manifest/manifest.go @@ -0,0 +1,261 @@ +package manifest + +import ( + "bytes" + "fmt" + "os" + "regexp" + "sort" + + "github.com/akonwi/ard/parse" + "github.com/pelletier/go-toml/v2" +) + +type BuildValueType string + +const ( + BuildValueStr BuildValueType = "Str" + BuildValueInt BuildValueType = "Int" + BuildValueBool BuildValueType = "Bool" +) + +type Document struct { + Name string + Ard string + Target string + Go GoConfig + Dependencies map[string]Dependency + Build BuildConfig +} + +type GoConfig struct { + BuildTags []string +} + +type Dependency struct { + Path string + Git string + Tag string + Commit string +} + +type BuildConfig struct { + Values map[string]BuildValue +} + +type BuildValue struct { + Type BuildValueType + Default any + Release bool +} + +func ParseFile(path string) (*Document, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return Parse(data) +} + +func Parse(data []byte) (*Document, error) { + var root map[string]any + decoder := toml.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&root); err != nil { + return nil, fmt.Errorf("invalid TOML: %w", err) + } + doc := &Document{Dependencies: map[string]Dependency{}, Build: BuildConfig{Values: map[string]BuildValue{}}} + var err error + if doc.Name, err = optionalString(root, "name", "name"); err != nil { + return nil, err + } + if doc.Ard, err = optionalString(root, "ard", "ard"); err != nil { + return nil, err + } + if doc.Target, err = optionalString(root, "target", "target"); err != nil { + return nil, err + } + if raw, ok := root["go"]; ok { + if doc.Go, err = parseGo(raw); err != nil { + return nil, err + } + } + if raw, ok := root["dependencies"]; ok { + if doc.Dependencies, err = parseDependencies(raw); err != nil { + return nil, err + } + } + if raw, ok := root["build"]; ok { + if doc.Build, err = parseBuild(raw); err != nil { + return nil, err + } + } + return doc, nil +} + +func parseGo(raw any) (GoConfig, error) { + table, ok := raw.(map[string]any) + if !ok { + return GoConfig{}, fmt.Errorf("go must be a table") + } + config := GoConfig{} + value, ok := table["build_tags"] + if !ok { + return config, nil + } + items, ok := value.([]any) + if !ok { + return GoConfig{}, fmt.Errorf("[go].build_tags must be a list of quoted strings") + } + validTag := regexp.MustCompile(`^[A-Za-z0-9_.]+$`) + for _, item := range items { + tag, ok := item.(string) + if !ok { + return GoConfig{}, fmt.Errorf("[go].build_tags must be a list of quoted strings") + } + if tag == "" || !validTag.MatchString(tag) { + return GoConfig{}, fmt.Errorf("invalid Go build tag %q", tag) + } + config.BuildTags = append(config.BuildTags, tag) + } + return config, nil +} + +func parseDependencies(raw any) (map[string]Dependency, error) { + table, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("dependencies must be a table") + } + dependencies := make(map[string]Dependency, len(table)) + for _, alias := range sortedKeys(table) { + rawDependency, ok := table[alias].(map[string]any) + if !ok { + return nil, fmt.Errorf("dependency %q must be a table", alias) + } + dependency := Dependency{} + var err error + if dependency.Path, err = optionalString(rawDependency, "path", "dependency path"); err != nil { + return nil, fmt.Errorf("dependency %q: %w", alias, err) + } + if dependency.Git, err = optionalString(rawDependency, "git", "dependency git"); err != nil { + return nil, fmt.Errorf("dependency %q: %w", alias, err) + } + if dependency.Tag, err = optionalString(rawDependency, "tag", "dependency tag"); err != nil { + return nil, fmt.Errorf("dependency %q: %w", alias, err) + } + if dependency.Commit, err = optionalString(rawDependency, "commit", "dependency commit"); err != nil { + return nil, fmt.Errorf("dependency %q: %w", alias, err) + } + dependencies[alias] = dependency + } + return dependencies, nil +} + +func parseBuild(raw any) (BuildConfig, error) { + table, ok := raw.(map[string]any) + if !ok { + return BuildConfig{}, fmt.Errorf("build must be a table") + } + if err := rejectUnknown(table, "build", "values"); err != nil { + return BuildConfig{}, err + } + config := BuildConfig{Values: map[string]BuildValue{}} + rawValues, ok := table["values"] + if !ok { + return config, nil + } + values, ok := rawValues.(map[string]any) + if !ok { + return BuildConfig{}, fmt.Errorf("build.values must be a table") + } + for _, name := range sortedKeys(values) { + if !parse.IsValidIdentifier(name) { + return BuildConfig{}, fmt.Errorf("build value %q is not a valid Ard identifier", name) + } + declaration, ok := values[name].(map[string]any) + if !ok { + return BuildConfig{}, fmt.Errorf("build value %q must be a table", name) + } + if err := rejectUnknown(declaration, "build value "+fmt.Sprintf("%q", name), "type", "default", "release"); err != nil { + return BuildConfig{}, err + } + rawType, ok := declaration["type"] + if !ok { + return BuildConfig{}, fmt.Errorf("build value %q is missing type", name) + } + typeName, ok := rawType.(string) + if !ok { + return BuildConfig{}, fmt.Errorf("build value %q type must be a string", name) + } + valueType := BuildValueType(typeName) + if valueType != BuildValueStr && valueType != BuildValueInt && valueType != BuildValueBool { + return BuildConfig{}, fmt.Errorf("build value %q has unsupported type %q", name, typeName) + } + defaultValue, ok := declaration["default"] + if !ok { + return BuildConfig{}, fmt.Errorf("build value %q is missing default", name) + } + if err := validateDefault(name, valueType, defaultValue); err != nil { + return BuildConfig{}, err + } + release := false + if rawRelease, ok := declaration["release"]; ok { + var valid bool + release, valid = rawRelease.(bool) + if !valid { + return BuildConfig{}, fmt.Errorf("build value %q release must be Bool", name) + } + } + config.Values[name] = BuildValue{Type: valueType, Default: defaultValue, Release: release} + } + return config, nil +} + +func validateDefault(name string, valueType BuildValueType, value any) error { + valid := false + switch valueType { + case BuildValueStr: + _, valid = value.(string) + case BuildValueInt: + _, valid = value.(int64) + case BuildValueBool: + _, valid = value.(bool) + } + if !valid { + return fmt.Errorf("build value %q default must be %s", name, valueType) + } + return nil +} + +func optionalString(table map[string]any, key, label string) (string, error) { + value, ok := table[key] + if !ok { + return "", nil + } + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", label) + } + return text, nil +} + +func rejectUnknown(table map[string]any, label string, allowed ...string) error { + known := make(map[string]bool, len(allowed)) + for _, key := range allowed { + known[key] = true + } + for _, key := range sortedKeys(table) { + if !known[key] { + return fmt.Errorf("%s has unknown field %q", label, key) + } + } + return nil +} + +func sortedKeys[V any](items map[string]V) []string { + keys := make([]string, 0, len(items)) + for key := range items { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/compiler/manifest/manifest_test.go b/compiler/manifest/manifest_test.go new file mode 100644 index 00000000..46c7dbf2 --- /dev/null +++ b/compiler/manifest/manifest_test.go @@ -0,0 +1,89 @@ +package manifest + +import ( + "strings" + "testing" +) + +func TestParseReadsCompleteManifest(t *testing.T) { + input := []byte(`name = "demo" +ard = ">= 0.40.0" +target = "go" + +[go] +build_tags = ["sqlite"] + +[dependencies] +ui = { path = "../ui" } +remote = { git = "https://example.com/remote.git", tag = "v1.2.3" } + +[build.values] +version = { type = "Str", default = "dev", release = true } +build_number = { type = "Int", default = 0 } +experimental = { type = "Bool", default = false } +`) + + got, err := Parse(input) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got.Name != "demo" || got.Ard != ">= 0.40.0" || got.Target != "go" { + t.Fatalf("manifest identity = %#v", got) + } + if len(got.Go.BuildTags) != 1 || got.Go.BuildTags[0] != "sqlite" { + t.Fatalf("build tags = %#v", got.Go.BuildTags) + } + if got.Dependencies["ui"].Path != "../ui" || got.Dependencies["remote"].Tag != "v1.2.3" { + t.Fatalf("dependencies = %#v", got.Dependencies) + } + if value := got.Build.Values["version"]; value.Type != BuildValueStr || value.Default != "dev" || !value.Release { + t.Fatalf("version = %#v", value) + } + if value := got.Build.Values["build_number"]; value.Type != BuildValueInt || value.Default != int64(0) || value.Release { + t.Fatalf("build_number = %#v", value) + } + if value := got.Build.Values["experimental"]; value.Type != BuildValueBool || value.Default != false { + t.Fatalf("experimental = %#v", value) + } +} + +func TestParseRejectsInvalidBuildValues(t *testing.T) { + tests := []struct { + name string + decl string + wantErr string + }{ + {name: "missing type", decl: `version = { default = "dev" }`, wantErr: `build value "version" is missing type`}, + {name: "missing default", decl: `version = { type = "Str" }`, wantErr: `build value "version" is missing default`}, + {name: "unsupported type", decl: `version = { type = "Float", default = 1.0 }`, wantErr: `unsupported type "Float"`}, + {name: "wrong string default", decl: `version = { type = "Str", default = 1 }`, wantErr: `default must be Str`}, + {name: "wrong int default", decl: `number = { type = "Int", default = "1" }`, wantErr: `default must be Int`}, + {name: "wrong bool default", decl: `enabled = { type = "Bool", default = "false" }`, wantErr: `default must be Bool`}, + {name: "bad release", decl: `version = { type = "Str", default = "dev", release = "yes" }`, wantErr: `release must be Bool`}, + {name: "unknown field", decl: `version = { type = "Str", default = "dev", secret = true }`, wantErr: `unknown field "secret"`}, + {name: "keyword", decl: `match = { type = "Str", default = "dev" }`, wantErr: `not a valid Ard identifier`}, + {name: "leading digit", decl: `123version = { type = "Str", default = "dev" }`, wantErr: `not a valid Ard identifier`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := []byte("name = \"demo\"\nard = \">= 0.40.0\"\n\n[build.values]\n" + tt.decl + "\n") + _, err := Parse(input) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Parse error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestParseRejectsMalformedCompleteManifest(t *testing.T) { + _, err := Parse([]byte("name = \"demo\"\nard = [\n")) + if err == nil { + t.Fatal("Parse succeeded for malformed TOML") + } +} + +func TestParseToleratesUnknownTopLevelFields(t *testing.T) { + if _, err := Parse([]byte("name = \"demo\"\nard = \">= 0.40.0\"\nmystery = true\n")); err != nil { + t.Fatalf("Parse: %v", err) + } +} diff --git a/compiler/parse/lexer.go b/compiler/parse/lexer.go index b8f60a2d..832ab3c0 100644 --- a/compiler/parse/lexer.go +++ b/compiler/parse/lexer.go @@ -1012,6 +1012,15 @@ func (l *lexer) takePath(start *char) (token, bool) { }, true } +// IsValidIdentifier reports whether name is an identifier token in Ard. It is +// intentionally stricter than accepting a name in an arbitrary grammar +// position: keywords cannot be addressed as static properties. +func IsValidIdentifier(name string) bool { + lexer := NewLexer([]byte(name)) + tokens := lexer.Scan() + return len(lexer.errors) == 0 && len(tokens) == 2 && tokens[0].kind == identifier && tokens[0].text == name && tokens[1].kind == eof +} + func (l *lexer) takeIdentifier() token { // record the start column column := l.column - 1 diff --git a/docs/adrs/0069-expose-manifest-build-values-through-ard-build.md b/docs/adrs/0069-expose-manifest-build-values-through-ard-build.md new file mode 100644 index 00000000..0cf2eb1e --- /dev/null +++ b/docs/adrs/0069-expose-manifest-build-values-through-ard-build.md @@ -0,0 +1,106 @@ +# 0069: Expose Manifest Build Values Through `ard/build` + +## Status + +Accepted + +## Context + +Applications commonly need release metadata such as a version, release channel, +or build number. Ard's Go target can inherit `go build` linker flags, but generated +Go package paths and symbols are compiler implementation details. Applications +currently need a Go FFI shim solely to provide a stable linker symbol. + +Build metadata must be typed before target lowering, deterministic for checking, +running, tests, and editor tooling, and portable to future targets. It must not +implicitly depend on environment variables, Git state, timestamps, or host state. + +ADR 0049 reserves exact compiler-owned module paths within `ard/*`. Providing +build values therefore also requires reserving a module path. + +## Decision + +Reserve `ard/build` as a compiler-owned synthetic module. The root manifest may +declare build values: + +```toml +[build.values] +version = { type = "Str", default = "dev", release = true } +channel = { type = "Str", default = "local" } +build_number = { type = "Int", default = 0 } +experimental = { type = "Bool", default = false } +``` + +Every declaration has a valid Ard identifier, an explicit type, and a default of +that type. Initially supported types are exactly `Str`, `Int`, and `Bool`. +`release` is optional and defaults to `false`. + +Root-package modules access values as immutable module symbols: + +```ard +use ard/build + +let version = build::version +``` + +The compiler constructs `ard/build` for each resolver invocation. Its symbols +are ordinary checked immutable globals initialized with target-neutral literals. +They lower through the existing checker → AIR → target pipeline. The module is +not source-backed and is never stored in the process-wide embedded-module cache. + +Dependencies cannot consume the root application's build values. A dependency +may use its own build declarations when compiled independently as a root project. +Importing `ard/build` without root declarations is an error. + +`ard build` accepts explicit overrides: + +```sh +ard build main.ard --define version=v1.2.3 --define build_number=42 +``` + +The text after the first `=` is the value. `Str` preserves it verbatim and may be +empty or contain additional `=` characters. `Bool` accepts exactly `true` or +`false`. `Int` accepts a signed decimal value in the current Ard `Int` range. +Unknown, malformed, or duplicate overrides are errors. + +`ard build --release` requires an explicit override for every declaration with +`release = true`. An override equal to the default still counts as explicit. +The policy applies to unused values. `--release` does not alter optimization, +backend settings, or defaults beyond enforcing this requirement. + +Commands without build options—`check`, `run`, `test`, and the LSP—use manifest +defaults. The compiler does not inspect ambient metadata automatically. Tooling +that wants Git or CI metadata must pass it explicitly with `--define`. + +Build values are embedded public artifact data and must not contain secrets. + +## Manifest parsing + +`ard.toml` is parsed as one complete TOML document into a shared typed manifest +model. Project identity, compiler constraint, Go configuration, dependencies, +and build values all derive from that parser rather than independent regular +expressions. Known sections validate their supported value shapes; unknown +non-build fields remain available for forward-compatible manifest evolution. +Malformed TOML and malformed build declarations fail project loading even when +no build value is referenced. + +Dependency-editing commands may continue to apply targeted textual edits so they +preserve comments and formatting, but semantic reads use the shared parser. + +## Consequences + +- Release tooling has a stable, typed, target-neutral metadata interface. +- Generated Go names and linker flags remain implementation details. +- `ard/build` becomes a permanently reserved compiler-owned path. +- Effective values are invocation-local and cannot leak between projects. +- Changing manifest defaults or declarations invalidates LSP analysis through + the existing manifest signature. +- The Go backend may represent immutable Ard globals as Go package variables; + immutability is enforced by Ard rather than exported as a Go API guarantee. + +## Related + +- `docs/adrs/0021-represent-module-level-lets-as-air-globals.md` +- `docs/adrs/0031-go-backend-lowering-contract.md` +- `docs/adrs/0049-overlay-ard-intrinsics-on-an-explicit-stdlib-package.md` +- GitHub issue #468 diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 8f9d5dba..2b59c2f0 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -202,6 +202,7 @@ export default defineConfig({ { label: "Pattern Matching", slug: "guide/pattern-matching" }, { label: "Modules", slug: "guide/modules" }, { label: "Dependencies", slug: "guide/dependencies" }, + { label: "Build values", slug: "guide/build-values" }, { label: "Testing", slug: "guide/testing" }, { label: "Formatting", slug: "guide/formatting" }, ], diff --git a/website/src/content/docs/guide/build-values.md b/website/src/content/docs/guide/build-values.md new file mode 100644 index 00000000..5ec6abcf --- /dev/null +++ b/website/src/content/docs/guide/build-values.md @@ -0,0 +1,75 @@ +--- +title: Build values +description: Embed typed application metadata at build time. +--- + +Build values provide application metadata such as versions, release channels, +and build numbers without relying on generated Go names or linker flags. + +## Declare values + +Declare values in the root project's `ard.toml`: + +```toml +[build.values] +version = { type = "Str", default = "dev", release = true } +release_channel = { type = "Str", default = "local" } +build_number = { type = "Int", default = 0 } +experimental = { type = "Bool", default = false } +``` + +Build value names must be valid Ard identifiers. Supported types are `Str`, +`Int`, and `Bool`. Every value requires a default, which is used by ordinary +builds, `ard check`, `ard run`, `ard test`, and editor tooling. + +Import the compiler-provided `ard/build` module to read the values: + +```ard +use ard/build + +fn version() Str { + build::version +} +``` + +Build values are immutable. They belong to the root application; dependencies +cannot read the application's values. + +## Override values + +Pass an override for each value that release tooling supplies: + +```sh +ard build main.ard \ + --define version=v1.2.3 \ + --define release_channel=stable \ + --define build_number=42 \ + --out example +``` + +`Str` values use all text after the first `=` and may be empty or contain more +`=` characters. `Bool` accepts exactly `true` or `false`. `Int` accepts signed +decimal values. + +Unknown values, duplicate overrides, and values with the wrong type are errors. + +## Release requirements + +A declaration with `release = true` must have an explicit override when building +with `--release`: + +```sh +ard build main.ard --release --define version=v1.2.3 +``` + +The default does not satisfy this requirement, even when the value is unused. +An explicit override equal to the default does satisfy it. `--release` only +enforces this policy; it does not change optimization or target settings. + +Ard does not inspect environment variables, Git state, timestamps, or hostnames. +Release tooling should collect that metadata and pass it explicitly. + +:::caution +Build values are embedded in the resulting artifact. Do not use them for +passwords, tokens, or other secrets. +:::