all repos

clerk @ 8909472

missing tooling for ledger/hledger

clerk/internal/settings/settings.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
feat: config (#6), 1 month ago
1
package settings
2
3
import (
4
	"errors"
5
	"fmt"
6
	"io/fs"
7
	"os"
8
	"strings"
9
10
	"github.com/pelletier/go-toml/v2"
11
	"olexsmir.xyz/clerk/internal/linter"
12
	"olexsmir.xyz/clerk/journal/printer"
13
)
14
15
type Settings struct {
16
	// SemanticHighlighting enables LSP semantic tokens.
17
	SemanticHighlighting bool
18
19
	// LatinToCyrillicCompletion matches Latin input against Cyrillic labels.
20
	LatinToCyrillicCompletion bool
21
22
	Linter linter.Config
23
	Format printer.Config
24
}
25
26
var DefaultConfig = Settings{
27
	SemanticHighlighting:      true,
28
	LatinToCyrillicCompletion: false,
29
	Linter:                    linter.DefaultConfig,
30
	Format:                    printer.DefaultConfig,
31
}
32
33
// Load reads and parses the TOML config file at path.
34
// A missing file yields defaults without error.
35
func Load(fpath string) (Settings, []string, error) {
36
	data, err := os.ReadFile(fpath)
37
	if err != nil {
38
		if errors.Is(err, fs.ErrNotExist) {
39
			return DefaultConfig, nil, nil
40
		}
41
		return DefaultConfig, nil, err
42
	}
43
	var raw map[string]any
44
	if err := toml.Unmarshal(data, &raw); err != nil {
45
		return DefaultConfig, nil, err
46
	}
47
	return parse(raw)
48
}
49
50
func parse(raw map[string]any) (settings Settings, warns []string, err error) {
51
	s := DefaultConfig
52
	warns, err = s.Apply(raw)
53
	return s, warns, err
54
}
55
56
// Apply merges raw setting from a config file into Settings object.
57
func (s *Settings) Apply(raw map[string]any) ([]string, error) {
58
	return applyMap(raw, s.applyFileField)
59
}
60
61
func (s *Settings) applyFileField(name string, val any) ([]string, error) {
62
	switch normalizeKey(name) {
63
	case "lint":
64
		return s.setLint(val)
65
	case "format":
66
		return s.setFormat(val)
67
	default:
68
		return []string{fmt.Sprintf("unknown setting %q", name)}, nil
69
	}
70
}
71
72
// ApplyLSP merges raw settings from lsp server config into Settings object.
73
func (s *Settings) ApplyLSP(raw map[string]any) ([]string, error) {
74
	return applyMap(raw, s.applyLSPField)
75
}
76
77
func (s *Settings) applyLSPField(name string, val any) ([]string, error) {
78
	switch normalizeKey(name) {
79
	case "semantic_highlighting":
80
		return nil, setBool(&s.SemanticHighlighting, val)
81
	case "latin_to_cyrillic_completion":
82
		return nil, setBool(&s.LatinToCyrillicCompletion, val)
83
	default:
84
		return s.applyFileField(name, val)
85
	}
86
}
87
88
func applyMap(m map[string]any, fn func(k string, val any) ([]string, error)) ([]string, error) {
89
	count := make(map[string]int, len(m))
90
	for k := range m {
91
		count[normalizeKey(k)]++
92
	}
93
	var (
94
		warns []string
95
		errs  []error
96
	)
97
	for k, val := range m {
98
		norm := normalizeKey(k)
99
		if count[norm] > 1 {
100
			errs = append(errs, fmt.Errorf("%s: duplicate setting %q not applied", k, norm))
101
			continue
102
		}
103
		ws, err := fn(k, val)
104
		warns = append(warns, ws...)
105
		if err != nil {
106
			errs = append(errs, prefixLines(k, err))
107
		}
108
	}
109
	return warns, errors.Join(errs...)
110
}
111
112
func applyTable(v any, fn func(k string, val any) ([]string, error)) ([]string, error) {
113
	m, ok := v.(map[string]any)
114
	if !ok {
115
		return nil, fmt.Errorf("invalid value %v (want table)", v)
116
	}
117
	return applyMap(m, fn)
118
}
119
120
func normalizeKey(s string) string {
121
	// already canonical(lowercase, digits, underscores)
122
	canonical := true
123
	for i := 0; i < len(s); i++ {
124
		c := s[i]
125
		if c != '_' && (c < 'a' || c > 'z') && (c < '0' || c > '9') {
126
			canonical = false
127
			break
128
		}
129
	}
130
	if canonical {
131
		return s
132
	}
133
134
	var b strings.Builder
135
	b.Grow(len(s))
136
	prevLower := false
137
	for i := 0; i < len(s); i++ {
138
		c := s[i]
139
		switch {
140
		case c == '-' || c == '_':
141
			b.WriteByte('_')
142
			prevLower = false
143
		case c >= 'A' && c <= 'Z':
144
			if prevLower {
145
				b.WriteByte('_')
146
			}
147
			b.WriteByte(c + 'a' - 'A')
148
			prevLower = false
149
		case c >= 'a' && c <= 'z':
150
			b.WriteByte(c)
151
			prevLower = true
152
		default:
153
			b.WriteByte(c)
154
			prevLower = false
155
		}
156
	}
157
	return b.String()
158
}
159
160
func prefixLines(prefix string, err error) error {
161
	lines := strings.Split(err.Error(), "\n")
162
	for i, line := range lines {
163
		if line != "" {
164
			lines[i] = prefix + ": " + line
165
		}
166
	}
167
	return errors.New(strings.Join(lines, "\n"))
168
}