package annotations import ( "fmt" "path" "sort" "strconv" "strings" "pvfine/internal/pvf" ) type Engine struct { source *Document version string scoped bool document Document rules []compiledRule // Path rules resolve once per tree node. Bulk path notes would otherwise // force a full glob scan per node, so they are bucketed by literal glob and // by the literal "/**" prefix at compile time. pathExact map[string][]int pathPrefix map[string][]int pathGeneral []int // scriptRules lists the non-path rules that annotate script tokens. scriptRules []int } // ContextResolver resolves an ID using an optional value from the same // repeated record. It extends Resolver for relations whose list path depends // on another token, such as skill IDs grouped by profession. type ContextResolver func(relation, id, context string) (Reference, bool) // ListResolver resolves one concrete list row. Unlike ContextResolver, it // receives the row's relative path as well, so duplicate IDs in a list still // navigate to the file represented by that particular row. type ListResolver func(relation, id, context, listPath, relativePath string) (Reference, bool) // PathResolver looks up a token path in the archive, using an optional rule root. type PathResolver func(root, value string) (int32, bool) type compiledRule struct { rule Rule extensions map[string]struct{} } type matchedItem struct { ruleID string title string content string typ string targetFileIndex int32 image *ImageReference inlineImage bool } func Compile(document Document) (*Engine, error) { normalizeImageTargets(&document) if document.Relations == nil { document.Relations = make(map[string]RelationSpec) } if document.Rules == nil { document.Rules = make([]Rule, 0) } if err := Validate(document); err != nil { return nil, err } return compileValidated(document), nil } // compileValidated only receives validated documents with valid field references. func compileValidated(document Document) *Engine { rules := make([]compiledRule, 0, len(document.Rules)+len(document.Fields)) appendRule := func(rule Rule) { extensions := make(map[string]struct{}, len(rule.Match.Extensions)) for _, extension := range rule.Match.Extensions { extensions[strings.ToLower(extension)] = struct{}{} } rules = append(rules, compiledRule{rule: rule, extensions: extensions}) } resolvedRules := make([]Rule, 0, len(document.Rules)) for _, originalRule := range document.Rules { rule, _ := resolveFieldRule(originalRule, document.Fields) resolvedRules = append(resolvedRules, rule) appendRule(rule) } // A shared field is itself an annotation definition. Explicit rules may // still reference a field to override its presentation; in that case the // explicit rule takes precedence and the implicit copy is skipped. for _, field := range document.Fields { fieldRule := Rule{ ID: "field:" + field.ID, Match: field.Match, Target: field.Target, Annotation: field.Annotation, Group: "field", } covered := false for _, rule := range resolvedRules { if rule.Field == field.ID || annotationRuleCoversField(rule, fieldRule) { covered = true break } } if !covered { appendRule(fieldRule) } } engine := &Engine{document: document, rules: rules} return engine.indexRules() } // indexRules buckets path rules so AnnotatePath only evaluates plausible // candidates, and records the script rules that Annotate walks. Rule order is // preserved because it decides the primary title of a path. func (e *Engine) indexRules() *Engine { for index := range e.rules { rule := &e.rules[index] if rule.rule.Target.Kind != "path" { // Token, section and range rules never take part in path lookups. e.scriptRules = append(e.scriptRules, index) continue } glob := normalizePath(rule.rule.Match.Glob) if prefix, ok := literalDirPrefix(glob); ok { e.pathPrefix = appendRuleIndex(e.pathPrefix, prefix, index) continue } if glob != "" && isLiteralGlob(glob) { e.pathExact = appendRuleIndex(e.pathExact, glob, index) continue } // An empty glob matches every path, and wildcard globs need a real match. e.pathGeneral = append(e.pathGeneral, index) } return e } func appendRuleIndex(buckets map[string][]int, key string, index int) map[string][]int { if buckets == nil { buckets = make(map[string][]int) } buckets[key] = append(buckets[key], index) return buckets } // isLiteralGlob reports whether a normalized glob matches exactly one path. func isLiteralGlob(glob string) bool { return !strings.ContainsAny(glob, `*?[]\`) } // literalDirPrefix returns the literal directory of a "/**" pattern. Such // a pattern matches the directory itself and every path below it. func literalDirPrefix(glob string) (string, bool) { if !strings.HasSuffix(glob, "/**") { return "", false } prefix := strings.TrimSuffix(glob, "/**") if prefix == "" || !isLiteralGlob(prefix) { return "", false } return prefix, true } // pathCandidateIndexes lists the rules worth evaluating for one path, in rule // order. It is a superset filter; callers still verify each rule with matches. func (e *Engine) pathCandidateIndexes(filePath string) []int { key := normalizePath(filePath) indexes := make([]int, 0, len(e.pathGeneral)+4) indexes = append(indexes, e.pathExact[key]...) for position := len(key); position > 0; position-- { if key[position-1] != '/' { continue } indexes = append(indexes, e.pathPrefix[key[:position-1]]...) } indexes = append(indexes, e.pathPrefix[key]...) indexes = append(indexes, e.pathGeneral...) sort.Ints(indexes) return indexes } func annotationRuleCoversField(rule, field Rule) bool { if rule.Annotation.Type != field.Annotation.Type || rule.Annotation.Title != field.Annotation.Title || rule.Annotation.Relation != field.Annotation.Relation || rule.Annotation.PathRoot != field.Annotation.PathRoot || !sameTarget(rule.Target, field.Target) { return false } return len(rule.Match.Extensions) == 0 && strings.TrimSpace(rule.Match.Glob) == "" || sameMatch(rule.Match, field.Match) } func sameMatch(left, right MatchSpec) bool { if !strings.EqualFold(strings.TrimSpace(left.Glob), strings.TrimSpace(right.Glob)) || len(left.Extensions) != len(right.Extensions) { return false } used := make([]bool, len(right.Extensions)) for _, extension := range left.Extensions { found := false for index, candidate := range right.Extensions { if !used[index] && strings.EqualFold(strings.TrimSpace(extension), strings.TrimSpace(candidate)) { used[index] = true found = true break } } if !found { return false } } return true } func sameTarget(left, right TargetSpec) bool { if left.Kind != right.Kind || !strings.EqualFold(left.Section, right.Section) || left.Offset != right.Offset || left.RecordTokens != right.RecordTokens || left.GroupOffset != right.GroupOffset || !sameInt32Slice(left.StandaloneValues, right.StandaloneValues) || !sameIntPtr(left.Index, right.Index) || !sameIntPtr(left.TokensPerLineIndex, right.TokensPerLineIndex) || !sameIntPtr(left.ContextIndex, right.ContextIndex) || !sameIntPtr(left.ImagePathToken, right.ImagePathToken) { return false } if (left.Range == nil) != (right.Range == nil) { return false } return left.Range == nil || (left.Range.Start == right.Range.Start && left.Range.EndExclusive == right.Range.EndExclusive) } func sameInt32Slice(left, right []int32) bool { if len(left) != len(right) { return false } for i := range left { if left[i] != right[i] { return false } } return true } func sameIntPtr(left, right *int) bool { if (left == nil) != (right == nil) { return false } return left == nil || *left == *right } func (e *Engine) Document() Document { if e.source != nil { return *e.source } return e.document } func (e *Engine) Annotate(filePath string, view pvf.ScriptView, resolver Resolver) []Result { return e.annotate(filePath, view, func(relation, id, _ string) (Reference, bool) { if resolver == nil { return Reference{}, false } return resolver(relation, id) }, nil, nil) } func (e *Engine) AnnotateWithContextResolver(filePath string, view pvf.ScriptView, resolver ContextResolver) []Result { return e.annotate(filePath, view, resolver, nil, nil) } // AnnotateWithContextAndListResolver resolves ID and list annotations. func (e *Engine) AnnotateWithContextAndListResolver(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver) []Result { return e.annotate(filePath, view, resolver, listResolver, nil) } // AnnotateWithResolvers also resolves path tokens to archive file indexes. func (e *Engine) AnnotateWithResolvers(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver, pathResolver PathResolver) []Result { return e.annotate(filePath, view, resolver, listResolver, pathResolver) } func (e *Engine) annotate(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver, pathResolver PathResolver) []Result { results := make([]Result, 0) resultByAnchor := make(map[string]int) appendResult := func(anchor editorAnchor, item matchedItem) { key := fmt.Sprintf("%d:%d", anchor.start, anchor.end) if index, ok := resultByAnchor[key]; ok { result := &results[index] result.Content = appendTooltip(result.Content, item.title, item.content) result.RuleIDs = append(result.RuleIDs, item.ruleID) if result.TargetFileIndex < 0 && item.targetFileIndex >= 0 { result.TargetFileIndex = item.targetFileIndex } if result.Image == nil && item.image != nil { result.Image = item.image } if item.image != nil && item.inlineImage { result.InlineImage = true } return } resultByAnchor[key] = len(results) results = append(results, Result{ Start: anchor.start, End: anchor.end, Title: item.title, Content: appendTooltip("", item.title, item.content), Type: item.typ, TargetFileIndex: item.targetFileIndex, RuleIDs: []string{item.ruleID}, Image: item.image, InlineImage: item.image != nil && item.inlineImage, }) } for _, index := range e.scriptRules { compiled := e.rules[index] rule := compiled.rule if !compiled.matches(filePath, false) { continue } anchors := e.editorAnchors(rule, view) for _, anchor := range anchors { value := anchor.value if rule.Annotation.Type == "image" { value = anchor.imagePathValue } item := annotationItem(rule, value, anchor.imageIndexValue, anchor.context, resolver, pathResolver) appendResult(anchor, item) } } for _, item := range e.listAnnotations(filePath, view, resolver, listResolver) { appendResult(item.anchor, item.item) } sort.SliceStable(results, func(i, j int) bool { if results[i].Start != results[j].Start { return results[i].Start < results[j].Start } return results[i].End < results[j].End }) return results } type listAnnotation struct { anchor editorAnchor item matchedItem } type listBinding struct { relationName string context string listPath string relation RelationSpec } // listAnnotations adds the implicit name/link annotations for a list file. // List files are formatted as fixed-size records, so the path token is the // natural anchor for both the displayed name and Cmd/Ctrl-click navigation. func (e *Engine) listAnnotations(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver) []listAnnotation { if resolver == nil && listResolver == nil { return nil } bindings := make([]listBinding, 0) seenBindings := make(map[string]struct{}) appendBinding := func(binding listBinding) { key := binding.relationName + "\x00" + normalizePath(binding.listPath) if _, exists := seenBindings[key]; exists { return } seenBindings[key] = struct{}{} bindings = append(bindings, binding) } for relationName, relation := range e.document.Relations { kind := relation.Kind if kind == "" { kind = "list" } switch kind { case "list": if sameListPath(relation.ListPath, filePath) { appendBinding(listBinding{ relationName: relationName, listPath: relation.ListPath, relation: e.relationWithDefaults(relation), }) } case "contextual": for context, listPath := range relation.ContextPaths { if !sameListPath(listPath, filePath) { continue } appendBinding(listBinding{ relationName: relationName, context: context, listPath: listPath, relation: e.relationWithDefaults(relation), }) } } } if len(bindings) == 0 { return nil } sort.SliceStable(bindings, func(i, j int) bool { if bindings[i].relationName != bindings[j].relationName { return bindings[i].relationName < bindings[j].relationName } if bindings[i].listPath != bindings[j].listPath { return bindings[i].listPath < bindings[j].listPath } return bindings[i].context < bindings[j].context }) tokens := make([]pvf.ScriptElement, 0, len(view.Elements)) for _, element := range view.Elements { if element.Kind == pvf.ScriptElementToken { tokens = append(tokens, element) } } results := make([]listAnnotation, 0) for _, binding := range bindings { relation := binding.relation for offset := 0; offset+relation.RecordTokens <= len(tokens); offset += relation.RecordTokens { id := tokens[offset+relation.IDToken].Value pathToken := tokens[offset+relation.PathToken] if id == "" || pathToken.Value == "" { continue } var reference Reference var ok bool if listResolver != nil { reference, ok = listResolver(binding.relationName, id, binding.context, binding.listPath, pathToken.Value) } else if resolver != nil { reference, ok = resolver(binding.relationName, id, binding.context) } if !ok || reference.FileIndex < 0 { continue } title := strings.TrimSpace(reference.Name) if title == "" { // A valid indexed file can still omit [name]. Keep the // annotation useful and, importantly, keep the path clickable. title = reference.Path } if title == "" { title = id } content := "ID: " + id if reference.Path != "" { content += "\n路径: " + reference.Path } results = append(results, listAnnotation{ anchor: editorAnchor{start: pathToken.Start, end: pathToken.End}, item: matchedItem{ ruleID: "list:" + binding.relationName, title: title, content: content, typ: "reference", targetFileIndex: reference.FileIndex, }, }) } } return results } func (e *Engine) relationWithDefaults(relation RelationSpec) RelationSpec { if relation.RecordTokens == 0 { relation.RecordTokens = max(relation.IDToken, relation.PathToken) + 1 } if relation.Kind == "" { relation.Kind = "list" } return relation } func (e *Engine) AnnotatePath(filePath string, isDir bool) []Result { items := make([]matchedItem, 0) for _, index := range e.pathCandidateIndexes(filePath) { compiled := e.rules[index] if !compiled.matches(filePath, isDir) { continue } items = append(items, annotationItem(compiled.rule, "", "", "", nil, nil)) } if len(items) == 0 { return nil } first := items[0] result := Result{ Title: first.title, Type: first.typ, TargetFileIndex: -1, RuleIDs: make([]string, 0, len(items)), } for _, item := range items { result.Content = appendTooltip(result.Content, item.title, item.content) result.RuleIDs = append(result.RuleIDs, item.ruleID) } return []Result{result} } func (e *Engine) Relation(name string) (RelationSpec, bool) { relation, ok := e.document.Relations[name] if ok && relation.RecordTokens == 0 { relation.RecordTokens = max(relation.IDToken, relation.PathToken) + 1 } if ok && relation.Kind == "" { relation.Kind = "list" } return relation, ok } func (e *Engine) RelationNames() []string { names := make([]string, 0, len(e.document.Relations)) for name := range e.document.Relations { names = append(names, name) } sort.Strings(names) return names } type editorAnchor struct { start int end int value string imagePathValue string imageIndexValue string context string } func (e *Engine) editorAnchors(rule Rule, view pvf.ScriptView) []editorAnchor { if rule.Target.Kind == "section" { anchors := make([]editorAnchor, 0) for _, element := range view.Elements { if element.Kind == pvf.ScriptElementSection && strings.EqualFold(element.Section, rule.Target.Section) { anchors = append(anchors, editorAnchor{start: element.Start, end: element.End}) } } return anchors } if rule.Target.Kind != "token" { return nil } if rule.Target.Index != nil && rule.Target.RecordTokens > 0 { contextIndex := rule.Target.ContextIndex if contextIndex == nil && rule.Annotation.Type == "reference" { if relation, ok := e.Relation(rule.Annotation.Relation); ok && relation.Kind == "contextual" { value := relation.ContextToken contextIndex = &value } } return repeatedTokenAnchors(rule, view, contextIndex) } if rule.Target.Index != nil { anchors := make([]editorAnchor, 0) for _, element := range view.Elements { if element.Kind == pvf.ScriptElementToken && strings.EqualFold(element.Section, rule.Target.Section) && element.Index == *rule.Target.Index { anchors = append(anchors, editorAnchor{start: element.Start, end: element.End, value: element.Value}) } } return anchors } ranges := make(map[int][]pvf.ScriptElement) sectionOrder := make([]int, 0) for _, element := range view.Elements { if element.Kind != pvf.ScriptElementToken || !strings.EqualFold(element.Section, rule.Target.Section) { continue } if _, ok := ranges[element.SectionID]; !ok { sectionOrder = append(sectionOrder, element.SectionID) } ranges[element.SectionID] = append(ranges[element.SectionID], element) } anchors := make([]editorAnchor, 0, len(sectionOrder)) for _, sectionID := range sectionOrder { var first, last *pvf.ScriptElement for i := range ranges[sectionID] { element := &ranges[sectionID][i] if element.Index < rule.Target.Range.Start || element.Index >= rule.Target.Range.EndExclusive { continue } if first == nil { first = element } last = element } if first != nil && last != nil { anchors = append(anchors, editorAnchor{start: first.Start, end: last.End}) } } return anchors } func repeatedTokenAnchors(rule Rule, view pvf.ScriptView, contextIndex *int) []editorAnchor { ranges := make(map[int][]pvf.ScriptElement) sectionOrder := make([]int, 0) for _, element := range view.Elements { if element.Kind != pvf.ScriptElementToken || !strings.EqualFold(element.Section, rule.Target.Section) { continue } if _, ok := ranges[element.SectionID]; !ok { sectionOrder = append(sectionOrder, element.SectionID) } ranges[element.SectionID] = append(ranges[element.SectionID], element) } anchors := make([]editorAnchor, 0) for _, sectionID := range sectionOrder { tokens := ranges[sectionID] recordTokens := repeatedRecordTokens(rule.Target, tokens) if recordTokens <= 0 { continue } for _, group := range repeatedTokenGroups(rule.Target, tokens) { for start := group.start; start+recordTokens <= group.end; start += recordTokens { targetOffset := *rule.Target.Index if targetOffset < 0 || targetOffset >= recordTokens { continue } target := tokens[start+targetOffset] anchor := editorAnchor{start: target.Start, end: target.End, value: target.Value} if rule.Annotation.Type == "image" && rule.Target.ImagePathToken != nil { imagePathOffset := *rule.Target.ImagePathToken if imagePathOffset < 0 || imagePathOffset >= recordTokens { continue } anchor.imagePathValue = tokens[start+imagePathOffset].Value anchor.imageIndexValue = target.Value } if contextIndex != nil { contextOffset := *contextIndex if contextOffset < 0 || contextOffset >= recordTokens { continue } anchor.context = tokens[start+contextOffset].Value } anchors = append(anchors, anchor) } } } return anchors } type tokenGroup struct{ start, end int } // repeatedTokenGroups applies the section-wide offset once, then splits at // standalone integer tokens. Each group's own offset is applied independently. func repeatedTokenGroups(target TargetSpec, tokens []pvf.ScriptElement) []tokenGroup { begin := min(target.Offset, len(tokens)) groups := make([]tokenGroup, 0, 1) appendGroup := func(end int) { start := min(begin+target.GroupOffset, end) groups = append(groups, tokenGroup{start: start, end: end}) } for index := begin; index < len(tokens); index++ { token := tokens[index] if token.TokenType != 0 { continue } value, err := strconv.ParseInt(strings.TrimSpace(token.Value), 10, 32) if err != nil { continue } for _, delimiter := range target.StandaloneValues { if int32(value) == delimiter { appendGroup(index) begin = index + 1 break } } } appendGroup(len(tokens)) return groups } // repeatedRecordTokens returns the fixed record width unless a section token // supplies a valid positive override. The dynamic index is relative to the // section's direct token sequence, just like rendering rules. func repeatedRecordTokens(target TargetSpec, tokens []pvf.ScriptElement) int { recordTokens := target.RecordTokens if target.TokensPerLineIndex == nil { return recordTokens } index := *target.TokensPerLineIndex if index < 0 || index >= len(tokens) { return recordTokens } value, err := strconv.Atoi(strings.TrimSpace(tokens[index].Value)) if err != nil || value <= 0 { return recordTokens } return value } func annotationItem(rule Rule, value, imageIndexValue, context string, resolver ContextResolver, pathResolver PathResolver) matchedItem { item := matchedItem{ ruleID: rule.ID, title: rule.Annotation.Title, content: strings.TrimSpace(rule.Annotation.Content), typ: rule.Annotation.Type, targetFileIndex: -1, inlineImage: rule.Annotation.InlineImage, } switch rule.Annotation.Type { case "path": item.content = joinContent(item.content, "路径: "+strings.TrimSpace(value)) if pathResolver != nil { if index, ok := pathResolver(rule.Annotation.PathRoot, value); ok { item.targetFileIndex = index } } case "image": imageIndex, err := strconv.ParseInt(strings.TrimSpace(imageIndexValue), 10, 32) item.content = joinContent(item.content, fmt.Sprintf("图片: %s[%s]", value, strings.TrimSpace(imageIndexValue))) if strings.TrimSpace(value) != "" && err == nil && imageIndex >= 0 { item.image = &ImageReference{Path: strings.TrimSpace(value), Index: int32(imageIndex)} } case "enum": label, ok := rule.Annotation.Values[value] detail := "当前值: " + value if ok { item.title = label detail = value + " - " + label } item.content = joinContent(item.content, detail) keys := make([]string, 0, len(rule.Annotation.Values)) for key := range rule.Annotation.Values { keys = append(keys, key) } sort.Strings(keys) entries := []string{"所有枚举值:"} for _, key := range keys { entries = append(entries, key+" - "+rule.Annotation.Values[key]) } item.content = joinContent(item.content, strings.Join(entries, "\n")) case "reference": detail := "ID: " + value + "(未找到关联文件)" if resolver != nil { if reference, ok := resolver(rule.Annotation.Relation, value, context); ok { if reference.Name != "" { item.title = reference.Name } detail = reference.Name if detail == "" { detail = reference.Path } if detail == "" { detail = "ID: " + value } else { detail += " (ID: " + value + ")" } item.targetFileIndex = reference.FileIndex } } item.content = joinContent(item.content, detail) } return item } func appendTooltip(current, title, content string) string { item := strings.TrimSpace(title) if detail := strings.TrimSpace(content); detail != "" { item = joinContent(item, detail) } if item == "" { return current } if current == "" { return item } return current + "\n\n" + item } func joinContent(first, second string) string { if first == "" { return second } if second == "" { return first } return first + "\n" + second } func (r compiledRule) matches(filePath string, isDir bool) bool { filePath = normalizePath(filePath) if len(r.extensions) > 0 { if isDir { return false } if _, ok := r.extensions[strings.ToLower(path.Ext(filePath))]; !ok { return false } } return r.rule.Match.Glob == "" || globMatch(r.rule.Match.Glob, filePath) } func normalizePath(value string) string { return strings.ToLower(strings.Trim(strings.ReplaceAll(value, "\\", "/"), "/")) } // sameListPath accepts the list/ location used by newer clients for a // relation configured with the older category directory layout. func sameListPath(configured, actual string) bool { configured, actual = normalizePath(configured), normalizePath(actual) if configured == actual { return true } if strings.HasPrefix(actual, "list/") && strings.Contains(configured, "/") { return path.Base(configured) == path.Base(actual) } if strings.HasPrefix(configured, "list/") && strings.Contains(actual, "/") { return path.Base(configured) == path.Base(actual) } return false } func globMatch(pattern, value string) bool { patternParts := splitPath(normalizePath(pattern)) valueParts := splitPath(normalizePath(value)) type state struct{ pattern, value int } memo := make(map[state]bool) seen := make(map[state]bool) var match func(int, int) bool match = func(pi, vi int) bool { key := state{pi, vi} if seen[key] { return memo[key] } seen[key] = true if pi == len(patternParts) { memo[key] = vi == len(valueParts) return memo[key] } if patternParts[pi] == "**" { if match(pi+1, vi) || (vi < len(valueParts) && match(pi, vi+1)) { memo[key] = true } return memo[key] } if vi >= len(valueParts) { return false } matched, err := path.Match(patternParts[pi], valueParts[vi]) if err == nil && matched { memo[key] = match(pi+1, vi+1) } return memo[key] } return match(0, 0) } func splitPath(value string) []string { if value == "" { return nil } return strings.Split(value, "/") } // ApplicableRuleIDs exposes resolved annotation applicability for the rule editor. // Token existence is evaluated by Annotate; here the file/section scope is reported. func (e *Engine) ApplicableRuleIDs(filePath, section string, isDir bool) map[string]bool { ids := map[string]bool{} if e == nil { return ids } for _, r := range e.rules { if !r.matches(filePath, isDir) { continue } if section != "" { if !strings.EqualFold(strings.TrimSpace(r.rule.Target.Section), strings.TrimSpace(section)) { continue } } else if r.rule.Target.Kind != "path" { continue } ids[r.rule.ID] = true } return ids }