From 6f6bf7080a1de6031cbeec5ed17e9942078da149 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sat, 18 Apr 2026 10:24:34 +0200 Subject: [PATCH] Move Generation Destinations --- internal/app/app.go | 4 +- internal/pipeline/build.go | 16 ++-- internal/pipeline/pipeline_test.go | 10 ++- internal/project/project.go | 17 +++- internal/project/project_test.go | 23 +++++ internal/topdata/native.go | 4 +- internal/topdata/top_package.go | 129 ++++++++++++++++----------- internal/topdata/topdata_test.go | 13 +-- internal/topdata/wiki_native.go | 25 +++--- internal/topdata/wiki_native_test.go | 16 ++-- 10 files changed, 166 insertions(+), 91 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index d755b97..0ca8844 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -70,12 +70,12 @@ var commands = []command{ }, { name: "build-topdata", - description: "Build canonical topdata outputs into topdata/assets/2da and build/sow_tlk.tlk, then package sow_top.hak.", + description: "Build canonical topdata outputs into .cache/topdata, then package sow_top.hak and sow_tlk.tlk.", run: runBuildTopData, }, { name: "build-top-package", - description: "Build sow_top.hak and sow_tlk.tlk from native topdata output and topdata/assets.", + description: "Build sow_top.hak and sow_tlk.tlk from cached native topdata output and authored topdata/assets.", run: runBuildTopPackage, }, { diff --git a/internal/pipeline/build.go b/internal/pipeline/build.go index 62cccf3..69f1513 100644 --- a/internal/pipeline/build.go +++ b/internal/pipeline/build.go @@ -467,11 +467,13 @@ func compileReferencedScripts(p *project.Project) ([]erf.Resource, error) { return nil, err } - tmpDir, err := os.MkdirTemp("", "sow-script-compile-*") - if err != nil { - return nil, fmt.Errorf("create script compiler temp dir: %w", err) + cacheDir := compiledScriptCacheDir(p) + if err := os.RemoveAll(cacheDir); err != nil { + return nil, fmt.Errorf("clear script compiler cache dir: %w", err) + } + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return nil, fmt.Errorf("create script compiler cache dir: %w", err) } - defer os.RemoveAll(tmpDir) resources := make([]erf.Resource, 0, len(referenced)) scriptsDir := filepath.Join(p.SourceDir(), "scripts") @@ -480,7 +482,7 @@ func compileReferencedScripts(p *project.Project) ([]erf.Resource, error) { if !ok { continue } - outputPath := filepath.Join(tmpDir, resref+".ncs") + outputPath := filepath.Join(cacheDir, resref+".ncs") cmd := exec.Command(compiler, "--dirs", scriptsDir, "-o", outputPath, sourcePath) cmd.Env = compilerEnv output, err := cmd.CombinedOutput() @@ -501,6 +503,10 @@ func compileReferencedScripts(p *project.Project) ([]erf.Resource, error) { return resources, nil } +func compiledScriptCacheDir(p *project.Project) string { + return filepath.Join(p.Root, ".cache", "ncs") +} + func referencedScriptResrefs(p *project.Project) ([]string, error) { referenced := map[string]struct{}{} for _, rel := range p.Inventory.SourceFiles { diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 238aa16..07fcb34 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -289,7 +289,7 @@ func TestBuildModuleCompilesReferencedScripts(t *testing.T) { mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "src", "placeables")) mustMkdir(t, filepath.Join(root, "src", "scripts")) - mustMkdir(t, filepath.Join(root, "assets")) + mustMkdir(t, filepath.Join(root, ".nwn-assets")) mustMkdir(t, filepath.Join(root, "build")) mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ @@ -299,7 +299,7 @@ func TestBuildModuleCompilesReferencedScripts(t *testing.T) { }, "paths": { "source": "src", - "assets": "assets", + "assets": ".nwn-assets", "build": "build" } } @@ -439,6 +439,12 @@ printf 'compiled:%s\nNWN_ROOT=%s\nNWN_HOME=%s\nNWN_USER_DIRECTORY=%s\n' "$(basen if _, err := os.Stat(userDir); err != nil { t.Fatalf("expected build to prepare NWN user directory: %v", err) } + if _, err := os.Stat(filepath.Join(root, ".cache", "ncs", "use_thing.ncs")); err != nil { + t.Fatalf("expected compiled script in .cache/ncs: %v", err) + } + if _, err := os.Stat(filepath.Join(root, ".nwn-assets", "ncs", "use_thing.ncs")); !os.IsNotExist(err) { + t.Fatalf("expected no compiled script in .nwn-assets/ncs, got err=%v", err) + } compareResult, err := Compare(p) if err != nil { diff --git a/internal/project/project.go b/internal/project/project.go index a944a95..2e157ef 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -145,6 +145,12 @@ func (p *Project) ValidateLayout() error { } { info, err := os.Stat(path) if err != nil { + if label == "paths.assets" && errors.Is(err, os.ErrNotExist) && p.isCachePath(path) { + if mkErr := os.MkdirAll(path, 0o755); mkErr != nil { + failures = append(failures, fmt.Errorf("%s: create cache directory %s: %w", label, path, mkErr)) + } + continue + } failures = append(failures, fmt.Errorf("%s: %w", label, err)) continue } @@ -160,6 +166,15 @@ func (p *Project) ValidateLayout() error { return nil } +func (p *Project) isCachePath(path string) bool { + rel, err := filepath.Rel(p.Root, path) + if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + return false + } + parts := strings.Split(filepath.Clean(rel), string(filepath.Separator)) + return len(parts) > 0 && parts[0] == ".cache" +} + func (p *Project) Scan() error { sourceFiles, sourceExts, err := scanDir(p.SourceDir(), func(path string) bool { ext := strings.ToLower(filepath.Ext(path)) @@ -334,7 +349,7 @@ func defaultConfig() Config { Build: "build", }, TopData: TopDataConfig{ - Build: "build/topdata", + Build: ".cache/topdata", }, } } diff --git a/internal/project/project_test.go b/internal/project/project_test.go index a18bc4a..d3ab26e 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -82,6 +82,29 @@ func TestTopDataAssetsDirReturnsEmptyForNoConfig(t *testing.T) { } } +func TestValidateLayoutCreatesMissingCacheAssetsDir(t *testing.T) { + root := t.TempDir() + mkdirAll(t, filepath.Join(root, "src")) + mkdirAll(t, filepath.Join(root, "build")) + + proj := &Project{ + Root: root, + Config: Config{ + Module: ModuleConfig{Name: "Test", ResRef: "test"}, + Paths: PathConfig{Source: "src", Assets: ".cache/module-assets", Build: "build"}, + }, + } + + if err := proj.ValidateLayout(); err != nil { + t.Fatalf("ValidateLayout returned error: %v", err) + } + if info, err := os.Stat(filepath.Join(root, ".cache", "module-assets")); err != nil { + t.Fatalf("expected cache assets dir to be created: %v", err) + } else if !info.IsDir() { + t.Fatalf("expected cache assets path to be a directory") + } +} + func mkdirAll(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(path, 0o755); err != nil { diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 0cfd182..69fa35d 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -162,8 +162,8 @@ func BuildNativeWithOptions(p *project.Project, opts NativeBuildOptions, progres var prebuiltWiki *wikiResult if opts.BuildWiki { if newestWikiSource, _, err := newestRelevantWikiSource(p.TopDataSourceDir()); err == nil && !newestWikiSource.IsZero() { - statePath := filepath.Join(p.TopDataSourceDir(), wikiRootDirName, wikiStateFileName) - outputDir := filepath.Join(p.TopDataSourceDir(), wikiRootDirName, wikiPagesDirName) + statePath := wikiOutputStatePath(p) + outputDir := wikiOutputPagesDir(p) if stateInfo, err := os.Stat(statePath); err == nil && !newestWikiSource.After(stateInfo.ModTime()) { if outputInfo, err := os.Stat(outputDir); err == nil && outputInfo.IsDir() { if state, err := loadWikiState(statePath); err == nil { diff --git a/internal/topdata/top_package.go b/internal/topdata/top_package.go index e6e94e3..71d8fc3 100644 --- a/internal/topdata/top_package.go +++ b/internal/topdata/top_package.go @@ -22,26 +22,35 @@ const ( ) func compiled2DAOutputDir(p *project.Project) string { - if useDedicatedTopDataBuildDir(p) { - return filepath.Join(p.TopDataBuildDir(), "2da") - } - return filepath.Join(p.TopDataSourceDir(), "assets", "2da") + return filepath.Join(p.TopDataBuildDir(), "2da") } func compiledTLKOutputPath(p *project.Project) string { - if useDedicatedTopDataBuildDir(p) { - return filepath.Join(p.TopDataBuildDir(), "tlk", defaultTLKName) - } - return filepath.Join(p.BuildDir(), defaultTLKName) + return filepath.Join(p.TopDataBuildDir(), "tlk", defaultTLKName) } func compiledTLKOutputDir(p *project.Project) string { return filepath.Dir(compiledTLKOutputPath(p)) } -func useDedicatedTopDataBuildDir(p *project.Project) bool { - defaultBuildDir := filepath.Join(p.BuildDir(), "topdata") - return p.TopDataBuildDir() != defaultBuildDir +func wikiOutputRootDir(p *project.Project) string { + return filepath.Join(p.TopDataBuildDir(), wikiRootDirName) +} + +func wikiOutputPagesDir(p *project.Project) string { + return filepath.Join(wikiOutputRootDir(p), wikiPagesDirName) +} + +func wikiOutputStatePath(p *project.Project) string { + return filepath.Join(wikiOutputRootDir(p), wikiStateFileName) +} + +func legacyCompiled2DAOutputDir(p *project.Project) string { + return filepath.Join(p.TopDataSourceDir(), "assets", "2da") +} + +func legacyWikiOutputRootDir(p *project.Project) string { + return filepath.Join(p.TopDataSourceDir(), legacyWikiRootDirName) } func BuildPackage(p *project.Project, progress func(string)) (PackageResult, error) { @@ -172,15 +181,20 @@ func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]er assetFiles := 0 assetsDir := filepath.Join(p.TopDataSourceDir(), "assets") - skipDir := compiled2DAOutputDir(p) + skipDirs := map[string]struct{}{ + filepath.Clean(compiled2DADir): {}, + filepath.Clean(legacyCompiled2DAOutputDir(p)): {}, + } info, err := os.Stat(assetsDir) if err == nil && info.IsDir() { err = filepath.WalkDir(assetsDir, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } - if d.IsDir() && path == skipDir { - return filepath.SkipDir + if d.IsDir() { + if _, ok := skipDirs[filepath.Clean(path)]; ok { + return filepath.SkipDir + } } if d.IsDir() { return nil @@ -220,6 +234,51 @@ func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]er return resources, assetFiles, nil } +func shouldSkipTopDataSourceDir(path string, skipDirs map[string]struct{}) bool { + _, ok := skipDirs[filepath.Clean(path)] + return ok +} + +func topDataSourceSkipDirs(p *project.Project) map[string]struct{} { + return map[string]struct{}{ + filepath.Clean(filepath.Join(p.TopDataSourceDir(), "templates")): {}, + filepath.Clean(legacyWikiOutputRootDir(p)): {}, + filepath.Clean(legacyCompiled2DAOutputDir(p)): {}, + filepath.Clean(wikiOutputRootDir(p)): {}, + } +} + +func newestTopDataSource(p *project.Project) (time.Time, string, error) { + newest := time.Time{} + newestPath := "" + skipDirs := topDataSourceSkipDirs(p) + sourceDir := p.TopDataSourceDir() + err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() && shouldSkipTopDataSourceDir(path, skipDirs) { + return filepath.SkipDir + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if newest.IsZero() || info.ModTime().After(newest) { + newest = info.ModTime() + newestPath = path + } + return nil + }) + if err != nil { + return time.Time{}, "", fmt.Errorf("scan topdata sources: %w", err) + } + return newest, newestPath, nil +} + func topPackageResourceFromPath(path string) (erf.Resource, error) { extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") resourceType, ok := erf.HAKResourceTypeForExtension(extension) @@ -335,7 +394,7 @@ func packagedBuildResult(p *project.Project) (BuildResult, error) { return BuildResult{}, fmt.Errorf("topdata build output missing: run build-topdata first") } - newestSource, newestPath, err := newestTopDataSource(p.TopDataSourceDir()) + newestSource, newestPath, err := newestTopDataSource(p) if err != nil { return BuildResult{}, err } @@ -390,7 +449,7 @@ func currentTopPackageResult(p *project.Project) (PackageResult, bool, error) { return PackageResult{}, false, fmt.Errorf("stat top package tlk %s: %w", outputTLK, err) } - newestSource, _, err := newestTopDataSource(p.TopDataSourceDir()) + newestSource, _, err := newestTopDataSource(p) if err != nil { return PackageResult{}, false, err } @@ -481,41 +540,3 @@ func countCompiledFiles(dir, extension string) (int, time.Time, error) { } return count, oldest, nil } - -func newestTopDataSource(sourceDir string) (time.Time, string, error) { - newest := time.Time{} - newestPath := "" - templatesDir := filepath.Join(sourceDir, "templates") - wikiDir := filepath.Join(sourceDir, wikiRootDirName) - generated2DADir := filepath.Join(sourceDir, "assets", "2da") - err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if d.IsDir() && path == templatesDir { - return filepath.SkipDir - } - if d.IsDir() && path == wikiDir { - return filepath.SkipDir - } - if d.IsDir() && path == generated2DADir { - return filepath.SkipDir - } - if d.IsDir() { - return nil - } - info, err := d.Info() - if err != nil { - return err - } - if newest.IsZero() || info.ModTime().After(newest) { - newest = info.ModTime() - newestPath = path - } - return nil - }) - if err != nil { - return time.Time{}, "", fmt.Errorf("scan topdata sources: %w", err) - } - return newest, newestPath, nil -} diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 9314e2d..073c8b3 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -4030,12 +4030,15 @@ func TestBuildNativeRemovesStaleTopdataOutputs(t *testing.T) { if err != nil { t.Fatalf("BuildNative failed: %v", err) } + if got, want := result.Output2DADir, filepath.Join(root, ".cache", "topdata", "2da"); got != want { + t.Fatalf("unexpected cached 2da output dir: got %q want %q", got, want) + } if _, err := os.Stat(filepath.Join(result.Output2DADir, "stale.2da")); !os.IsNotExist(err) { - t.Fatalf("expected stale topdata output to be removed, stat err=%v", err) + t.Fatalf("expected stale legacy topdata output to be ignored, stat err=%v", err) } } -func TestBuildNativeWritesCompiledOutputsToAssetsAndBuild(t *testing.T) { +func TestBuildNativeWritesCompiledOutputsToCache(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") @@ -4053,10 +4056,10 @@ func TestBuildNativeWritesCompiledOutputsToAssetsAndBuild(t *testing.T) { if err != nil { t.Fatalf("BuildNative failed: %v", err) } - if got, want := result.Output2DADir, filepath.Join(root, "topdata", "assets", "2da"); got != want { + if got, want := result.Output2DADir, filepath.Join(root, ".cache", "topdata", "2da"); got != want { t.Fatalf("unexpected 2da output dir: got %q want %q", got, want) } - if got, want := result.OutputTLKDir, filepath.Join(root, "build"); got != want { + if got, want := result.OutputTLKDir, filepath.Join(root, ".cache", "topdata", "tlk"); got != want { t.Fatalf("unexpected tlk output dir: got %q want %q", got, want) } if _, err := os.Stat(filepath.Join(result.Output2DADir, "repadjust.2da")); err != nil { @@ -9522,7 +9525,7 @@ func testProject(root string) *project.Project { Paths: project.PathConfig{Source: "src", Assets: "assets", Build: "build"}, TopData: project.TopDataConfig{ Source: "topdata", - Build: "build/topdata", + Build: ".cache/topdata", ReferenceBuilder: "reference", Assets: ".", }, diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go index 53bd4bd..e900a21 100644 --- a/internal/topdata/wiki_native.go +++ b/internal/topdata/wiki_native.go @@ -17,15 +17,16 @@ import ( ) const ( - wikiStatusNew = "new" - wikiStatusModified = "modified" - wikiStatusVanilla = "vanilla" - wikiRootDirName = ".wiki" - wikiPagesDirName = "pages" - wikiStateFileName = "state.json" - wikiGeneratorVersion = "native-v1" - wikiGeneratedStatus = "generated" - wikiSkippedStatus = "skipped" + wikiStatusNew = "new" + wikiStatusModified = "modified" + wikiStatusVanilla = "vanilla" + wikiRootDirName = "wiki" + legacyWikiRootDirName = ".wiki" + wikiPagesDirName = "pages" + wikiStateFileName = "state.json" + wikiGeneratorVersion = "native-v1" + wikiGeneratedStatus = "generated" + wikiSkippedStatus = "skipped" ) var ( @@ -83,9 +84,9 @@ func buildWiki(p *project.Project, nativeResult BuildResult, progress func(strin if progress == nil { progress = func(string) {} } - rootDir := filepath.Join(p.TopDataSourceDir(), wikiRootDirName) - outputDir := filepath.Join(rootDir, wikiPagesDirName) - statePath := filepath.Join(rootDir, wikiStateFileName) + rootDir := wikiOutputRootDir(p) + outputDir := wikiOutputPagesDir(p) + statePath := wikiOutputStatePath(p) digest, err := computeWikiSourceDigest(p.TopDataSourceDir()) if err != nil { diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index 7108d1c..b5d5e74 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -42,7 +42,7 @@ func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) { if result.WikiPages != 1 { t.Fatalf("expected one generated entity page, got %d", result.WikiPages) } - pagePath := filepath.Join(root, "topdata", ".wiki", "pages", "skills", "athletics.txt") + pagePath := filepath.Join(root, ".cache", "topdata", "wiki", "pages", "skills", "athletics.txt") got, err := os.ReadFile(pagePath) if err != nil { t.Fatalf("read generated page: %v", err) @@ -54,7 +54,7 @@ func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) { if !strings.Contains(text, "Ability: Strength.") || !strings.Contains(text, " * Climb") { t.Fatalf("expected description to be rendered into wiki page:\n%s", text) } - statusPath := filepath.Join(root, "topdata", ".wiki", "pages", "meta", "wikistatus.txt") + statusPath := filepath.Join(root, ".cache", "topdata", "wiki", "pages", "meta", "wikistatus.txt") statusRaw, err := os.ReadFile(statusPath) if err != nil { t.Fatalf("read status page: %v", err) @@ -62,10 +62,10 @@ func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) { if !strings.Contains(string(statusRaw), "**Vanilla:** 1\\\\") { t.Fatalf("expected wiki status summary to count vanilla page:\n%s", string(statusRaw)) } - if _, err := os.Stat(filepath.Join(root, "topdata", ".wiki", "state.json")); err != nil { + if _, err := os.Stat(filepath.Join(root, ".cache", "topdata", "wiki", "state.json")); err != nil { t.Fatalf("expected state file after first build: %v", err) } - stateBefore, err := loadWikiState(filepath.Join(root, "topdata", ".wiki", "state.json")) + stateBefore, err := loadWikiState(filepath.Join(root, ".cache", "topdata", "wiki", "state.json")) if err != nil { t.Fatalf("read state before second build: %v", err) } @@ -120,7 +120,7 @@ func TestBuildAndPackageBuildsWikiOnlyWhenRequested(t *testing.T) { if result.WikiOutputDir != "" || result.WikiPages != 0 { t.Fatalf("expected wiki generation to be skipped by default, got dir=%q pages=%d", result.WikiOutputDir, result.WikiPages) } - if _, err := os.Stat(filepath.Join(root, "topdata", ".wiki", "state.json")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(root, ".cache", "topdata", "wiki", "state.json")); !os.IsNotExist(err) { t.Fatalf("expected no wiki state without explicit wiki build, got %v", err) } @@ -131,7 +131,7 @@ func TestBuildAndPackageBuildsWikiOnlyWhenRequested(t *testing.T) { if result.WikiPages != 1 { t.Fatalf("expected one generated wiki page when requested, got %d", result.WikiPages) } - if _, err := os.Stat(filepath.Join(root, "topdata", ".wiki", "state.json")); err != nil { + if _, err := os.Stat(filepath.Join(root, ".cache", "topdata", "wiki", "state.json")); err != nil { t.Fatalf("expected wiki state after explicit wiki build: %v", err) } } @@ -189,7 +189,7 @@ func TestBuildNativeRegeneratesWikiWhenTLKTextChanges(t *testing.T) { if _, err := BuildNative(proj, nil); err != nil { t.Fatalf("BuildNative after text change failed: %v", err) } - pagePath := filepath.Join(root, "topdata", ".wiki", "pages", "skills", "athletics.txt") + pagePath := filepath.Join(root, ".cache", "topdata", "wiki", "pages", "skills", "athletics.txt") got, err := os.ReadFile(pagePath) if err != nil { t.Fatalf("read regenerated page: %v", err) @@ -230,7 +230,7 @@ func TestBuildPackageIgnoresWikiSourcesForFreshness(t *testing.T) { t.Fatalf("BuildNative failed: %v", err) } - pagePath := filepath.Join(root, "topdata", ".wiki", "pages", "skills", "athletics.txt") + pagePath := filepath.Join(root, ".cache", "topdata", "wiki", "pages", "skills", "athletics.txt") newTime := time.Now().Add(2 * time.Second) if err := os.Chtimes(pagePath, newTime, newTime); err != nil { t.Fatalf("touch wiki page: %v", err)