服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
876 lines
27 KiB
Go
876 lines
27 KiB
Go
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 "<dir>/**" 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 "<dir>/**" 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
|
|
}
|