DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,197 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Engine is an immutable, compiled set of script rendering rules.
|
||||
type Engine struct {
|
||||
document Document
|
||||
rules []compiledRule
|
||||
}
|
||||
|
||||
type compiledRule struct {
|
||||
rule Rule
|
||||
extensions map[string]struct{}
|
||||
specificity int
|
||||
order int
|
||||
}
|
||||
|
||||
// Compile validates and compiles a rendering rule document.
|
||||
func Compile(document Document) (*Engine, error) {
|
||||
if document.Rules == nil {
|
||||
document.Rules = make([]Rule, 0)
|
||||
}
|
||||
if err := Validate(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]compiledRule, 0, len(document.Rules))
|
||||
for order, rule := range document.Rules {
|
||||
extensions := make(map[string]struct{}, len(rule.Match.Extensions))
|
||||
for _, extension := range rule.Match.Extensions {
|
||||
extensions[strings.ToLower(strings.TrimSpace(extension))] = struct{}{}
|
||||
}
|
||||
specificity := 0
|
||||
if len(rule.Match.Extensions) > 0 {
|
||||
specificity++
|
||||
}
|
||||
if strings.TrimSpace(rule.Match.Glob) != "" {
|
||||
specificity++
|
||||
}
|
||||
rules = append(rules, compiledRule{
|
||||
rule: rule,
|
||||
extensions: extensions,
|
||||
specificity: specificity,
|
||||
order: order,
|
||||
})
|
||||
}
|
||||
return &Engine{document: document, rules: rules}, nil
|
||||
}
|
||||
|
||||
// Document returns the source document used to build the engine.
|
||||
func (e *Engine) Document() Document {
|
||||
if e == nil {
|
||||
return Document{}
|
||||
}
|
||||
document := e.document
|
||||
document.Rules = append([]Rule(nil), e.document.Rules...)
|
||||
for index := range document.Rules {
|
||||
if widths := document.Rules[index].Format.TokensPerLineByVersion; widths != nil {
|
||||
cloned := make(map[string]int, len(widths))
|
||||
for version, width := range widths {
|
||||
cloned[version] = width
|
||||
}
|
||||
document.Rules[index].Format.TokensPerLineByVersion = cloned
|
||||
}
|
||||
document.Rules[index].Match.Extensions = append([]string(nil), document.Rules[index].Match.Extensions...)
|
||||
document.Rules[index].Format.StandaloneValues = append([]int32(nil), document.Rules[index].Format.StandaloneValues...)
|
||||
document.Rules[index].Format.NestedSections = append([]string(nil), document.Rules[index].Format.NestedSections...)
|
||||
if value := document.Rules[index].Format.TokensPerLineIndex; value != nil {
|
||||
copy := *value
|
||||
document.Rules[index].Format.TokensPerLineIndex = ©
|
||||
}
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
// FileFormat resolves the best file-scoped rule for filePath. A zero value
|
||||
// means that the decoder should use its ordinary layout.
|
||||
func (e *Engine) FileFormat(filePath string) FormatSpec {
|
||||
return e.resolve(filePath, "", "file")
|
||||
}
|
||||
|
||||
// SectionFormat resolves the best section-scoped rule for a section name.
|
||||
// Section names are intentionally matched without considering nesting or
|
||||
// occurrence, matching the annotation engine's V1 semantics.
|
||||
func (e *Engine) SectionFormat(filePath, section string) FormatSpec {
|
||||
return e.resolve(filePath, section, "section")
|
||||
}
|
||||
|
||||
func (e *Engine) resolve(filePath, section, kind string) FormatSpec {
|
||||
if e == nil {
|
||||
return FormatSpec{}
|
||||
}
|
||||
bestSpecificity := -1
|
||||
bestOrder := -1
|
||||
var format FormatSpec
|
||||
for _, compiled := range e.rules {
|
||||
rule := compiled.rule
|
||||
if rule.Target.Kind != kind || !compiled.matches(filePath) {
|
||||
continue
|
||||
}
|
||||
if kind == "section" && !strings.EqualFold(strings.TrimSpace(rule.Target.Section), strings.TrimSpace(section)) {
|
||||
continue
|
||||
}
|
||||
if compiled.specificity < bestSpecificity ||
|
||||
(compiled.specificity == bestSpecificity && compiled.order < bestOrder) {
|
||||
continue
|
||||
}
|
||||
bestSpecificity = compiled.specificity
|
||||
bestOrder = compiled.order
|
||||
format = compiled.rule.Format
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
func (r compiledRule) matches(filePath string) bool {
|
||||
filePath = normalizePath(filePath)
|
||||
if len(r.extensions) > 0 {
|
||||
if _, ok := r.extensions[strings.ToLower(path.Ext(filePath))]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
glob := strings.TrimSpace(r.rule.Match.Glob)
|
||||
return glob == "" || globMatch(glob, filePath)
|
||||
}
|
||||
|
||||
func normalizePath(value string) string {
|
||||
return strings.ToLower(strings.Trim(strings.ReplaceAll(value, "\\", "/"), "/"))
|
||||
}
|
||||
|
||||
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(patternIndex, valueIndex int) bool {
|
||||
key := state{pattern: patternIndex, value: valueIndex}
|
||||
if seen[key] {
|
||||
return memo[key]
|
||||
}
|
||||
seen[key] = true
|
||||
if patternIndex == len(patternParts) {
|
||||
memo[key] = valueIndex == len(valueParts)
|
||||
return memo[key]
|
||||
}
|
||||
if patternParts[patternIndex] == "**" {
|
||||
if match(patternIndex+1, valueIndex) ||
|
||||
(valueIndex < len(valueParts) && match(patternIndex, valueIndex+1)) {
|
||||
memo[key] = true
|
||||
}
|
||||
return memo[key]
|
||||
}
|
||||
if valueIndex >= len(valueParts) {
|
||||
return false
|
||||
}
|
||||
matched, err := path.Match(patternParts[patternIndex], valueParts[valueIndex])
|
||||
if err == nil && matched {
|
||||
memo[key] = match(patternIndex+1, valueIndex+1)
|
||||
}
|
||||
return memo[key]
|
||||
}
|
||||
return match(0, 0)
|
||||
}
|
||||
|
||||
func splitPath(value string) []string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(value, "/")
|
||||
}
|
||||
|
||||
// EffectiveRuleID reports the selected rule for management UI diagnostics.
|
||||
func (e *Engine) EffectiveRuleID(filePath, section, kind string) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
bestSpecificity, bestOrder := -1, -1
|
||||
id := ""
|
||||
for _, compiled := range e.rules {
|
||||
r := compiled.rule
|
||||
if r.Target.Kind != kind || !compiled.matches(filePath) {
|
||||
continue
|
||||
}
|
||||
if kind == "section" && !strings.EqualFold(strings.TrimSpace(r.Target.Section), strings.TrimSpace(section)) {
|
||||
continue
|
||||
}
|
||||
if compiled.specificity < bestSpecificity || (compiled.specificity == bestSpecificity && compiled.order < bestOrder) {
|
||||
continue
|
||||
}
|
||||
bestSpecificity, bestOrder, id = compiled.specificity, compiled.order, r.ID
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package rendering
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatMatchingUsesExtensionSectionAndGlob(t *testing.T) {
|
||||
engine, err := Compile(Document{
|
||||
Version: 1,
|
||||
Rules: []Rule{
|
||||
{
|
||||
ID: "file.lst",
|
||||
Match: MatchSpec{Extensions: []string{".LST"}},
|
||||
Target: TargetSpec{Kind: "file"},
|
||||
Format: FormatSpec{TokensPerLine: 2},
|
||||
},
|
||||
{
|
||||
ID: "file.skl",
|
||||
Match: MatchSpec{Extensions: []string{".skl"}},
|
||||
Target: TargetSpec{Kind: "file"},
|
||||
Format: FormatSpec{TokensPerLine: 2},
|
||||
},
|
||||
{
|
||||
ID: "section.global",
|
||||
Target: TargetSpec{Kind: "section", Section: "Records"},
|
||||
Format: FormatSpec{TokensPerLine: 3},
|
||||
},
|
||||
{
|
||||
ID: "section.skl",
|
||||
Match: MatchSpec{Extensions: []string{".skl"}},
|
||||
Target: TargetSpec{Kind: "section", Section: "records"},
|
||||
Format: FormatSpec{TokensPerLine: 4},
|
||||
},
|
||||
{
|
||||
ID: "section.skl.glob",
|
||||
Match: MatchSpec{Extensions: []string{".SKL"}, Glob: "skills/**/*.skl"},
|
||||
Target: TargetSpec{Kind: "section", Section: "RECORDS"},
|
||||
Format: FormatSpec{TokensPerLine: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := engine.FileFormat("foo/INDEX.LST").TokensPerLine; got != 2 {
|
||||
t.Fatalf("lst file format = %d, want 2", got)
|
||||
}
|
||||
if got := engine.FileFormat("skills/test.SKL").TokensPerLine; got != 2 {
|
||||
t.Fatalf("skl file format = %d, want 2", got)
|
||||
}
|
||||
if got := engine.SectionFormat("other/item.skl", "records").TokensPerLine; got != 4 {
|
||||
t.Fatalf("skl section format = %d, want 4", got)
|
||||
}
|
||||
if got := engine.SectionFormat("skills/advanced/item.SKL", "RECORDS").TokensPerLine; got != 5 {
|
||||
t.Fatalf("glob section format = %d, want 5", got)
|
||||
}
|
||||
if got := engine.SectionFormat("item.equ", "records").TokensPerLine; got != 3 {
|
||||
t.Fatalf("global section format = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMatchingUsesLaterRuleForEqualSpecificity(t *testing.T) {
|
||||
engine, err := Compile(Document{
|
||||
Version: 1,
|
||||
Rules: []Rule{
|
||||
{
|
||||
ID: "first",
|
||||
Match: MatchSpec{Extensions: []string{".equ"}},
|
||||
Target: TargetSpec{Kind: "file"},
|
||||
Format: FormatSpec{TokensPerLine: 2},
|
||||
},
|
||||
{
|
||||
ID: "second",
|
||||
Match: MatchSpec{Extensions: []string{".EQU"}},
|
||||
Target: TargetSpec{Kind: "file"},
|
||||
Format: FormatSpec{TokensPerLine: 7},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := engine.FileFormat("item.equ").TokensPerLine; got != 7 {
|
||||
t.Fatalf("equal-specificity format = %d, want 7", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
appconfig "pvfine/config"
|
||||
)
|
||||
|
||||
const sourceRelativePath = "config/rendering.json"
|
||||
|
||||
// LoadDefault loads the embedded rendering rules used at application start.
|
||||
func LoadDefault() (*Engine, error) {
|
||||
return Parse(appconfig.RenderingJSON)
|
||||
}
|
||||
|
||||
// LoadFile loads and validates a rendering rule document from disk.
|
||||
func LoadFile(filePath string) (*Engine, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取渲染规则失败: %w", err)
|
||||
}
|
||||
engine, err := Parse(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// Parse parses and validates one complete JSON rendering document.
|
||||
func Parse(data []byte) (*Engine, error) {
|
||||
document, err := parseDocument(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Compile(document)
|
||||
}
|
||||
|
||||
// ParseDocument parses one rendering document without compiling its rules.
|
||||
func parseDocument(data []byte) (Document, error) {
|
||||
var document Document
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return Document{}, fmt.Errorf("解析渲染规则失败: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return Document{}, fmt.Errorf("渲染规则只能包含一个 JSON 文档")
|
||||
}
|
||||
return Document{}, fmt.Errorf("解析渲染规则失败: %w", err)
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
// Marshal validates and formats a rendering document.
|
||||
func Marshal(document Document) ([]byte, error) {
|
||||
if err := Validate(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.MarshalIndent(document, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
// Validate checks the V1 rendering schema and all matching constraints.
|
||||
func Validate(document Document) error {
|
||||
problems := make([]string, 0)
|
||||
if document.Version != 1 {
|
||||
problems = append(problems, fmt.Sprintf("version 必须为 1,当前为 %d", document.Version))
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]bool, len(document.Rules))
|
||||
for index, rule := range document.Rules {
|
||||
prefix := fmt.Sprintf("rules[%d]", index)
|
||||
id := strings.TrimSpace(rule.ID)
|
||||
if id == "" {
|
||||
problems = append(problems, prefix+".id 不能为空")
|
||||
} else if seenIDs[id] {
|
||||
problems = append(problems, prefix+".id 重复: "+rule.ID)
|
||||
}
|
||||
seenIDs[id] = true
|
||||
|
||||
for _, rawExtension := range rule.Match.Extensions {
|
||||
extension := strings.ToLower(strings.TrimSpace(rawExtension))
|
||||
if extension == "" || !strings.HasPrefix(extension, ".") || strings.ContainsAny(extension, "/\\") {
|
||||
problems = append(problems, prefix+".match.extensions 必须是以点开头的文件后缀")
|
||||
break
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rule.Match.Glob) != "" {
|
||||
if err := validateGlob(rule.Match.Glob); err != nil {
|
||||
problems = append(problems, prefix+".match.glob 无效: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
switch rule.Target.Kind {
|
||||
case "file":
|
||||
if len(rule.Format.NestedSections) > 0 {
|
||||
problems = append(problems, prefix+".format.nestedSections 只允许用于 section 规则")
|
||||
}
|
||||
if strings.TrimSpace(rule.Target.Section) != "" {
|
||||
problems = append(problems, prefix+".target.section 仅允许用于 section 规则")
|
||||
}
|
||||
if rule.Format.TokensPerLineIndex != nil {
|
||||
problems = append(problems, prefix+".format.tokensPerLineIndex 只允许用于 section 规则")
|
||||
}
|
||||
case "section":
|
||||
if strings.TrimSpace(rule.Target.Section) == "" {
|
||||
problems = append(problems, prefix+".target.section 不能为空")
|
||||
}
|
||||
default:
|
||||
problems = append(problems, prefix+".target.kind 必须是 file 或 section")
|
||||
}
|
||||
for _, child := range rule.Format.NestedSections {
|
||||
if strings.TrimSpace(child) == "" || strings.ContainsAny(child, "[]/") {
|
||||
problems = append(problems, prefix+".format.nestedSections 必须包含有效的子 Section 名称")
|
||||
break
|
||||
}
|
||||
}
|
||||
if rule.Format.Offset < 0 {
|
||||
problems = append(problems, prefix+".format.offset 不能为负数")
|
||||
}
|
||||
if rule.Format.TokensPerLine <= 0 {
|
||||
problems = append(problems, prefix+".format.tokensPerLine 必须是正数")
|
||||
}
|
||||
for version, width := range rule.Format.TokensPerLineByVersion {
|
||||
switch version {
|
||||
case "90US", "90CN", "TW", "100CN", "110US", "115US", "115CN":
|
||||
default:
|
||||
problems = append(problems, prefix+".format.tokensPerLineByVersion 包含无效版本 "+version)
|
||||
}
|
||||
if width <= 0 {
|
||||
problems = append(problems, prefix+".format.tokensPerLineByVersion 的行宽必须是正数")
|
||||
}
|
||||
}
|
||||
if rule.Format.TokensPerLineIndex != nil && *rule.Format.TokensPerLineIndex < 0 {
|
||||
problems = append(problems, prefix+".format.tokensPerLineIndex 不能为负数")
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("渲染规则校验失败:\n- %s", strings.Join(problems, "\n- "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGlob(pattern string) error {
|
||||
for _, segment := range strings.Split(normalizePath(pattern), "/") {
|
||||
if segment == "**" {
|
||||
continue
|
||||
}
|
||||
if _, err := path.Match(segment, segment); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RuntimePath is the writable rule location used by packaged applications.
|
||||
func RuntimePath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取用户配置目录失败: %w", err)
|
||||
}
|
||||
return filepath.Join(configDir, "pvfine", "rendering.json"), nil
|
||||
}
|
||||
|
||||
// FindSourcePath locates the repository rendering file during development.
|
||||
func FindSourcePath() (string, bool) {
|
||||
starts := make([]string, 0, 2)
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
starts = append(starts, cwd)
|
||||
}
|
||||
if executable, err := os.Executable(); err == nil {
|
||||
starts = append(starts, filepath.Dir(executable))
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, start := range starts {
|
||||
for dir := filepath.Clean(start); ; dir = filepath.Dir(dir) {
|
||||
if _, ok := seen[dir]; !ok {
|
||||
seen[dir] = struct{}{}
|
||||
candidate := filepath.Join(dir, filepath.FromSlash(sourceRelativePath))
|
||||
if fileExists(filepath.Join(dir, "go.mod")) && fileExists(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func fileExists(filePath string) bool {
|
||||
info, err := os.Stat(filePath)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDefault(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDefault() error = %v", err)
|
||||
}
|
||||
rules := engine.Document().Rules
|
||||
if len(rules) < 3 {
|
||||
t.Fatalf("default rule count = %d, want at least 3", len(rules))
|
||||
}
|
||||
seen := make(map[string]bool, len(rules))
|
||||
for _, rule := range rules {
|
||||
seen[rule.ID] = true
|
||||
}
|
||||
for _, id := range []string{"file.lst", "section.independent-drop", "section.list", "section.skill-data-up", "section.skill-levelup"} {
|
||||
if !seen[id] {
|
||||
t.Fatalf("default rules missing %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsInvalidDocuments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "version",
|
||||
json: `{"version":2,"rules":[]}`,
|
||||
want: "version 必须为 1",
|
||||
},
|
||||
{
|
||||
name: "duplicate id",
|
||||
json: `{"version":1,"rules":[{"id":"same","match":{},"target":{"kind":"file"},"format":{"tokensPerLine":2}},{"id":"same","match":{},"target":{"kind":"file"},"format":{"tokensPerLine":3}}]}`,
|
||||
want: "id 重复",
|
||||
},
|
||||
{
|
||||
name: "extension",
|
||||
json: `{"version":1,"rules":[{"id":"bad","match":{"extensions":["lst"]},"target":{"kind":"file"},"format":{"tokensPerLine":2}}]}`,
|
||||
want: "必须是以点开头的文件后缀",
|
||||
},
|
||||
{
|
||||
name: "target",
|
||||
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"section"},"format":{"tokensPerLine":2}}]}`,
|
||||
want: "target.section 不能为空",
|
||||
},
|
||||
{
|
||||
name: "format",
|
||||
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"file"},"format":{"tokensPerLine":0}}]}`,
|
||||
want: "tokensPerLine 必须是正数",
|
||||
},
|
||||
{
|
||||
name: "offset",
|
||||
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"file"},"format":{"offset":-1,"tokensPerLine":2}}]}`,
|
||||
want: "format.offset 不能为负数",
|
||||
},
|
||||
{
|
||||
name: "dynamic file rule",
|
||||
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"file"},"format":{"tokensPerLine":2,"tokensPerLineIndex":0}}]}`,
|
||||
want: "tokensPerLineIndex 只允许用于 section 规则",
|
||||
},
|
||||
{
|
||||
name: "dynamic index",
|
||||
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"section","section":"records"},"format":{"tokensPerLine":2,"tokensPerLineIndex":-1}}]}`,
|
||||
want: "tokensPerLineIndex 不能为负数",
|
||||
},
|
||||
{
|
||||
name: "nested sections on file",
|
||||
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"file"},"format":{"tokensPerLine":2,"nestedSections":["list"]}}]}`,
|
||||
want: "nestedSections 只允许用于 section 规则",
|
||||
},
|
||||
{
|
||||
name: "invalid nested section",
|
||||
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"section","section":"records"},"format":{"tokensPerLine":2,"nestedSections":["[/list]"]}}]}`,
|
||||
want: "nestedSections 必须包含有效的子 Section 名称",
|
||||
},
|
||||
{
|
||||
name: "glob",
|
||||
json: `{"version":1,"rules":[{"id":"bad","match":{"glob":"["},"target":{"kind":"file"},"format":{"tokensPerLine":2}}]}`,
|
||||
want: "match.glob 无效",
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
json: `{"version":1,"rules":[],"extra":true}`,
|
||||
want: "解析渲染规则失败",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := Parse([]byte(tt.json))
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Parse() error = %v, want substring %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsTrailingJSON(t *testing.T) {
|
||||
_, err := Parse([]byte(`{"version":1,"rules":[]} {}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "只能包含一个 JSON 文档") {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionWidthsValidateAndClone(t *testing.T) {
|
||||
for _, spec := range []string{`{"TW":0}`, `{"unknown":6}`, `{"":6}`} {
|
||||
raw := `{"version":1,"rules":[{"id":"width","match":{},"target":{"kind":"section","section":"records"},"format":{"tokensPerLine":7,"tokensPerLineByVersion":` + spec + `}}]}`
|
||||
if _, err := Parse([]byte(raw)); err == nil {
|
||||
t.Fatal("accepted invalid version widths", spec)
|
||||
}
|
||||
}
|
||||
engine, err := Parse([]byte(`{"version":1,"rules":[{"id":"width","match":{},"target":{"kind":"section","section":"records"},"format":{"tokensPerLine":7,"tokensPerLineByVersion":{"TW":6,"110US":9,"100CN":8}}}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document := engine.Document()
|
||||
document.Rules[0].Format.TokensPerLineByVersion["TW"] = 99
|
||||
if got := engine.SectionFormat("etc/test.etc", "records").ForVersion("TW").TokensPerLine; got != 6 {
|
||||
t.Fatal("document mutation changed engine", got)
|
||||
}
|
||||
if got := engine.SectionFormat("etc/test.etc", "records").ForVersion("unknown").TokensPerLine; got != 7 {
|
||||
t.Fatal("unknown version did not use fallback", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package rendering
|
||||
|
||||
// Document is the JSON document that describes TypeScript display rules.
|
||||
type Document struct {
|
||||
Version int `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Rules []Rule `json:"rules"`
|
||||
}
|
||||
|
||||
// Rule describes one file- or section-scoped rendering format.
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Match MatchSpec `json:"match"`
|
||||
Target TargetSpec `json:"target"`
|
||||
Format FormatSpec `json:"format"`
|
||||
}
|
||||
|
||||
// MatchSpec limits a rule to selected file extensions and/or archive paths.
|
||||
// Matching is case-insensitive.
|
||||
type MatchSpec struct {
|
||||
Extensions []string `json:"extensions,omitempty"`
|
||||
Glob string `json:"glob,omitempty"`
|
||||
}
|
||||
|
||||
// TargetSpec selects whether a rule applies to a complete file or to all
|
||||
// sections with one name.
|
||||
type TargetSpec struct {
|
||||
Kind string `json:"kind"`
|
||||
Section string `json:"section,omitempty"`
|
||||
}
|
||||
|
||||
// FormatSpec contains the display formatting knobs supported by V1.
|
||||
type FormatSpec struct {
|
||||
// Offset places the first Offset value tokens on separate lines before
|
||||
// TokensPerLine grouping begins.
|
||||
Offset int `json:"offset,omitempty"`
|
||||
// TokensPerLine wraps value tokens after this many tokens. It is also the
|
||||
// fallback when TokensPerLineIndex is configured but its value is missing
|
||||
// or invalid.
|
||||
TokensPerLine int `json:"tokensPerLine"`
|
||||
// TokensPerLineByVersion overrides the fallback width for known clients.
|
||||
TokensPerLineByVersion map[string]int `json:"tokensPerLineByVersion,omitempty"`
|
||||
// TokensPerLineIndex is a zero-based direct token index inside a section;
|
||||
// its positive integer value overrides TokensPerLine for that section.
|
||||
TokensPerLineIndex *int `json:"tokensPerLineIndex,omitempty"`
|
||||
// StandaloneValues are integer tokens rendered on their own line. Each
|
||||
// occurrence resets the tokens-per-line counter for subsequent values.
|
||||
StandaloneValues []int32 `json:"standaloneValues,omitempty"`
|
||||
// NestedSections are paired child sections which preserve an unpaired parent.
|
||||
NestedSections []string `json:"nestedSections,omitempty"`
|
||||
}
|
||||
|
||||
// ForVersion resolves the configured width before any token-index override.
|
||||
func (f FormatSpec) ForVersion(version string) FormatSpec {
|
||||
if width := f.TokensPerLineByVersion[version]; width > 0 {
|
||||
f.TokensPerLine = width
|
||||
}
|
||||
return f
|
||||
}
|
||||
Reference in new issue
Block a user