package validator import ( "encoding/json" "fmt" "os" "path/filepath" "slices" "strings" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) type Severity string const ( SeverityError Severity = "error" SeverityWarning Severity = "warning" ) type Diagnostic struct { Path string Message string Severity Severity } type Report struct { Diagnostics []Diagnostic SourceCount int } type loadedDocument struct { Path string ResRef string Extension string Document gff.Document } type assetOccurrence struct { Path string Group string } type assetGroupResolver struct { configs []project.HAKConfig defaultGroup string } var requiredFields = map[string][]string{ "ifo": {"Mod_Name"}, } var builtinScriptPrefixes = []string{ "nw_", "x0_", "x1_", "x2_", "x3_", "ga_", "gc_", "gen_", "gui_", "nwg_", "ta_", } func ValidateProject(p *project.Project) Report { report := Report{} resourceIndex := map[string]string{} scriptIndex := map[string]string{} assetIndex := map[string]string{} assetOccurrences := map[string][]assetOccurrence{} documents := make([]loadedDocument, 0, len(p.Inventory.SourceFiles)) resolver := newAssetGroupResolver(p) for _, rel := range p.Inventory.SourceFiles { if hasUppercaseResourceName(rel) { report.add(rel, "resource filenames should be lowercase", SeverityWarning) } abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel)) document, resref, extension, err := loadDocument(abs) if err != nil { report.add(rel, err.Error(), SeverityError) continue } report.SourceCount++ documents = append(documents, loadedDocument{ Path: rel, ResRef: resref, Extension: extension, Document: document, }) key := strings.ToLower(resref) + "." + extension if previous, exists := resourceIndex[key]; exists { report.add(rel, fmt.Sprintf("duplicate resource %s also defined by %s", key, previous), SeverityError) } else { resourceIndex[key] = rel } validateDocumentStructure(&report, rel, extension, document) } for _, rel := range p.Inventory.ScriptFiles { if hasUppercaseResourceName(rel) { report.add(rel, "resource filenames should be lowercase", SeverityWarning) } base := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))) if previous, exists := scriptIndex[base]; exists { report.add(rel, fmt.Sprintf("duplicate script resource %s also defined by %s", base, previous), SeverityError) } else { scriptIndex[base] = rel } } for _, rel := range p.Inventory.AssetFiles { if hasUppercaseResourceName(rel) { report.add(rel, "resource filenames should be lowercase", SeverityWarning) } base := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))) extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(rel)), ".") key := strings.ToLower(base) + "." + extension if _, exists := assetIndex[key]; !exists { assetIndex[key] = rel } group, err := resolver.groupFor(rel) if err != nil { report.add(rel, err.Error(), SeverityError) continue } assetOccurrences[key] = append(assetOccurrences[key], assetOccurrence{ Path: rel, Group: group, }) } classifyAssetDuplicates(&report, assetOccurrences) for _, document := range documents { validateReferences(&report, document, resourceIndex, scriptIndex, assetIndex) } slices.SortFunc(report.Diagnostics, func(a, b Diagnostic) int { if cmp := strings.Compare(a.Path, b.Path); cmp != 0 { return cmp } if cmp := strings.Compare(string(a.Severity), string(b.Severity)); cmp != 0 { return cmp } return strings.Compare(a.Message, b.Message) }) return report } func (r *Report) add(path, message string, severity Severity) { r.Diagnostics = append(r.Diagnostics, Diagnostic{Path: path, Message: message, Severity: severity}) } func (r Report) HasErrors() bool { return r.ErrorCount() > 0 } func (r Report) ErrorCount() int { count := 0 for _, diagnostic := range r.Diagnostics { if diagnostic.Severity == SeverityError { count++ } } return count } func (r Report) WarningCount() int { count := 0 for _, diagnostic := range r.Diagnostics { if diagnostic.Severity == SeverityWarning { count++ } } return count } func loadDocument(path string) (gff.Document, string, string, error) { raw, err := os.ReadFile(path) if err != nil { return gff.Document{}, "", "", fmt.Errorf("read file: %w", err) } stem := strings.TrimSuffix(filepath.Base(path), ".json") extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(stem)), ".") if extension == "" { return gff.Document{}, "", "", fmt.Errorf("missing resource extension before .json") } resref := strings.ToLower(strings.TrimSuffix(stem, "."+extension)) var document gff.Document if err := json.Unmarshal(raw, &document); err != nil { return gff.Document{}, "", "", fmt.Errorf("parse json: %w", err) } return document, resref, extension, nil } func validateDocumentStructure(report *Report, path, extension string, document gff.Document) { expectedType := strings.ToUpper(extension) if strings.TrimSpace(document.FileType) != "" && strings.TrimSpace(document.FileType) != expectedType { report.add(path, fmt.Sprintf("file_type %q does not match expected %q", document.FileType, expectedType), SeverityError) } required := requiredFields[extension] if len(required) > 0 { fields := map[string]struct{}{} for _, field := range document.Root.Fields { fields[field.Label] = struct{}{} } for _, label := range required { if _, ok := fields[label]; !ok { report.add(path, fmt.Sprintf("missing required field %q", label), SeverityError) } } } validateUniqueLabels(report, path, "root", document.Root) } func validateUniqueLabels(report *Report, path, scope string, s gff.Struct) { seen := map[string]struct{}{} for _, field := range s.Fields { if _, exists := seen[field.Label]; exists { report.add(path, fmt.Sprintf("duplicate field label %q in %s", field.Label, scope), SeverityError) } else { seen[field.Label] = struct{}{} } switch value := field.Value.(type) { case gff.Struct: validateUniqueLabels(report, path, scope+"."+field.Label, value) case gff.ListValue: for index, item := range value { validateUniqueLabels(report, path, fmt.Sprintf("%s.%s[%d]", scope, field.Label, index), item) } } } } func validateReferences(report *Report, document loadedDocument, resources, scripts, assets map[string]string) { walkFields(document.Document.Root, func(field gff.Field) { value, ok := fieldStringValue(field.Value) if !ok || value == "" { return } switch { case isScriptField(field.Label): if isBuiltinScript(value) { return } if _, exists := scripts[strings.ToLower(value)]; !exists { report.add(document.Path, fmt.Sprintf("missing script reference %q from field %q", value, field.Label), SeverityError) } case field.Label == "Conversation": key := strings.ToLower(value) + ".dlg" if _, exists := resources[key]; !exists { report.add(document.Path, fmt.Sprintf("missing dialog reference %q", key), SeverityError) } case field.Label == "Mod_Entry_Area": key := strings.ToLower(value) + ".are" if _, exists := resources[key]; !exists { report.add(document.Path, fmt.Sprintf("missing area reference %q", key), SeverityError) } case field.Label == "Model": if !hasAsset(strings.ToLower(value), []string{"mdl"}, assets) { report.add(document.Path, fmt.Sprintf("missing model asset for %q", value), SeverityError) } case field.Label == "Sound": if !hasAsset(strings.ToLower(value), []string{"wav"}, assets) { report.add(document.Path, fmt.Sprintf("missing sound asset for %q", value), SeverityError) } } }) } func classifyAssetDuplicates(report *Report, occurrences map[string][]assetOccurrence) { keys := make([]string, 0, len(occurrences)) for key := range occurrences { keys = append(keys, key) } slices.Sort(keys) for _, key := range keys { entries := occurrences[key] if len(entries) < 2 { continue } groups := make([]string, 0, len(entries)) groupSet := map[string]struct{}{} paths := make([]string, 0, len(entries)) for _, entry := range entries { paths = append(paths, entry.Path) if _, exists := groupSet[entry.Group]; exists { continue } groupSet[entry.Group] = struct{}{} groups = append(groups, entry.Group) } slices.Sort(groups) slices.Sort(paths) if len(groups) == 1 { report.add(entries[0].Path, fmt.Sprintf("duplicate asset resource %s appears multiple times in hak group %q (%s); build will fail if duplicates land in the same generated hak", key, groups[0], strings.Join(paths, ", ")), SeverityWarning) continue } report.add(entries[0].Path, fmt.Sprintf("duplicate asset resource %s is split across hak groups %s (%s); later hak order overrides earlier at runtime", key, strings.Join(groups, ", "), strings.Join(paths, ", ")), SeverityWarning) } } func newAssetGroupResolver(p *project.Project) assetGroupResolver { configs := make([]project.HAKConfig, len(p.Config.HAKs)) copy(configs, p.Config.HAKs) slices.SortFunc(configs, func(a, b project.HAKConfig) int { if a.Priority != b.Priority { if a.Priority < b.Priority { return -1 } return 1 } return strings.Compare(a.Name, b.Name) }) return assetGroupResolver{configs: configs, defaultGroup: p.Config.Module.ResRef} } func (r assetGroupResolver) groupFor(rel string) (string, error) { if len(r.configs) == 0 { return r.defaultGroup, nil } for _, cfg := range r.configs { if matchesAnyPattern(rel, cfg.Include) { return cfg.Name, nil } } return "", fmt.Errorf("asset does not match any hak include pattern") } func matchesAnyPattern(path string, patterns []string) bool { for _, pattern := range patterns { if matchPathPattern(filepath.ToSlash(path), filepath.ToSlash(pattern)) { return true } } return false } func matchPathPattern(path, pattern string) bool { pathSegs := strings.Split(strings.Trim(path, "/"), "/") patternSegs := strings.Split(strings.Trim(pattern, "/"), "/") return matchSegments(pathSegs, patternSegs) } func matchSegments(pathSegs, patternSegs []string) bool { if len(patternSegs) == 0 { return len(pathSegs) == 0 } if patternSegs[0] == "**" { if matchSegments(pathSegs, patternSegs[1:]) { return true } if len(pathSegs) > 0 { return matchSegments(pathSegs[1:], patternSegs) } return false } if len(pathSegs) == 0 { return false } ok, err := filepath.Match(patternSegs[0], pathSegs[0]) if err != nil || !ok { return false } return matchSegments(pathSegs[1:], patternSegs[1:]) } func walkFields(s gff.Struct, visit func(gff.Field)) { for _, field := range s.Fields { visit(field) switch value := field.Value.(type) { case gff.Struct: walkFields(value, visit) case gff.ListValue: for _, item := range value { walkFields(item, visit) } } } } func hasUppercaseResourceName(rel string) bool { base := filepath.Base(rel) return base != strings.ToLower(base) } func fieldStringValue(value gff.Value) (string, bool) { switch typed := value.(type) { case gff.StringValue: return string(typed), true case gff.ResRefValue: return string(typed), true default: return "", false } } func isScriptField(label string) bool { return strings.HasPrefix(label, "On") || strings.HasSuffix(label, "Script") || strings.Contains(label, "Script") } func hasAsset(name string, extensions []string, assets map[string]string) bool { for _, extension := range extensions { if _, exists := assets[name+"."+extension]; exists { return true } } return false } func isBuiltinScript(name string) bool { lower := strings.ToLower(name) for _, prefix := range builtinScriptPrefixes { if strings.HasPrefix(lower, prefix) { return true } } return false } func ResourceTypeForSourceExtension(extension string) (uint16, bool) { return erf.ResourceTypeForExtension(extension) }