blob: dd1e3e91a6382777bb10acfd16cbb5b071352b3c [file] [log] [blame]
Akron57ee5582025-05-21 15:25:13 +02001package config
2
3import (
4 "fmt"
5 "os"
6
Akronfa55bb22025-05-26 15:10:42 +02007 "github.com/KorAP/KoralPipe-TermMapper/ast"
8 "github.com/KorAP/KoralPipe-TermMapper/parser"
Akron57ee5582025-05-21 15:25:13 +02009 "gopkg.in/yaml.v3"
10)
11
Akron06d21f02025-06-04 14:36:07 +020012const (
Akrona8a66ce2025-06-05 10:50:17 +020013 defaultServer = "https://korap.ids-mannheim.de/"
14 defaultSDK = "https://korap.ids-mannheim.de/js/korap-plugin-latest.js"
15 defaultPort = 3000
16 defaultLogLevel = "warn"
Akron06d21f02025-06-04 14:36:07 +020017)
18
Akron57ee5582025-05-21 15:25:13 +020019// MappingRule represents a single mapping rule in the configuration
20type MappingRule string
21
22// MappingList represents a list of mapping rules with metadata
23type MappingList struct {
Akrondab27112025-06-05 13:52:43 +020024 ID string `yaml:"id"`
25 Description string `yaml:"desc,omitempty"`
26 FoundryA string `yaml:"foundryA,omitempty"`
27 LayerA string `yaml:"layerA,omitempty"`
28 FoundryB string `yaml:"foundryB,omitempty"`
29 LayerB string `yaml:"layerB,omitempty"`
30 Mappings []MappingRule `yaml:"mappings"`
Akron57ee5582025-05-21 15:25:13 +020031}
32
Akron06d21f02025-06-04 14:36:07 +020033// MappingConfig represents the root configuration containing multiple mapping lists
34type MappingConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020035 SDK string `yaml:"sdk,omitempty"`
36 Server string `yaml:"server,omitempty"`
37 Port int `yaml:"port,omitempty"`
38 LogLevel string `yaml:"loglevel,omitempty"`
39 Lists []MappingList `yaml:"lists,omitempty"`
Akron57ee5582025-05-21 15:25:13 +020040}
41
Akrone1cff7c2025-06-04 18:43:32 +020042// LoadFromSources loads configuration from multiple sources and merges them:
43// - A main configuration file (optional) containing global settings and lists
44// - Individual mapping files (optional) containing single mapping lists each
45// At least one source must be provided
46func LoadFromSources(configFile string, mappingFiles []string) (*MappingConfig, error) {
47 var allLists []MappingList
48 var globalConfig MappingConfig
Akron57ee5582025-05-21 15:25:13 +020049
Akrone1cff7c2025-06-04 18:43:32 +020050 // Track seen IDs across all sources to detect duplicates
51 seenIDs := make(map[string]bool)
Akrona5d88142025-05-22 14:42:09 +020052
Akrone1cff7c2025-06-04 18:43:32 +020053 // Load main configuration file if provided
54 if configFile != "" {
55 data, err := os.ReadFile(configFile)
56 if err != nil {
57 return nil, fmt.Errorf("failed to read config file '%s': %w", configFile, err)
Akron06d21f02025-06-04 14:36:07 +020058 }
Akrone1cff7c2025-06-04 18:43:32 +020059
60 if len(data) == 0 {
61 return nil, fmt.Errorf("EOF: config file '%s' is empty", configFile)
62 }
63
64 // Try to unmarshal as new format first (object with optional sdk/server and lists)
65 if err := yaml.Unmarshal(data, &globalConfig); err == nil && len(globalConfig.Lists) > 0 {
66 // Successfully parsed as new format with lists field
67 for _, list := range globalConfig.Lists {
68 if seenIDs[list.ID] {
69 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
70 }
71 seenIDs[list.ID] = true
72 }
73 allLists = append(allLists, globalConfig.Lists...)
74 } else {
75 // Fall back to old format (direct list)
76 var lists []MappingList
77 if err := yaml.Unmarshal(data, &lists); err != nil {
78 return nil, fmt.Errorf("failed to parse YAML config file '%s': %w", configFile, err)
79 }
80
81 for _, list := range lists {
82 if seenIDs[list.ID] {
83 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
84 }
85 seenIDs[list.ID] = true
86 }
87 allLists = append(allLists, lists...)
88 // Clear the lists from globalConfig since we got them from the old format
89 globalConfig.Lists = nil
90 }
Akron06d21f02025-06-04 14:36:07 +020091 }
92
Akrone1cff7c2025-06-04 18:43:32 +020093 // Load individual mapping files
94 for _, file := range mappingFiles {
95 data, err := os.ReadFile(file)
96 if err != nil {
97 return nil, fmt.Errorf("failed to read mapping file '%s': %w", file, err)
98 }
99
100 if len(data) == 0 {
101 return nil, fmt.Errorf("EOF: mapping file '%s' is empty", file)
102 }
103
104 var list MappingList
105 if err := yaml.Unmarshal(data, &list); err != nil {
106 return nil, fmt.Errorf("failed to parse YAML mapping file '%s': %w", file, err)
107 }
108
109 if seenIDs[list.ID] {
110 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
111 }
112 seenIDs[list.ID] = true
113 allLists = append(allLists, list)
Akron57ee5582025-05-21 15:25:13 +0200114 }
115
Akrone1cff7c2025-06-04 18:43:32 +0200116 // Ensure we have at least some configuration
117 if len(allLists) == 0 {
118 return nil, fmt.Errorf("no mapping lists found: provide either a config file (-c) with lists or mapping files (-m)")
119 }
120
121 // Validate all mapping lists
122 if err := validateMappingLists(allLists); err != nil {
Akron06d21f02025-06-04 14:36:07 +0200123 return nil, err
124 }
125
Akrone1cff7c2025-06-04 18:43:32 +0200126 // Create final configuration
127 result := &MappingConfig{
128 SDK: globalConfig.SDK,
129 Server: globalConfig.Server,
130 Lists: allLists,
131 }
132
Akron06d21f02025-06-04 14:36:07 +0200133 // Apply defaults if not specified
Akrone1cff7c2025-06-04 18:43:32 +0200134 applyDefaults(result)
135
136 return result, nil
137}
138
139// LoadConfig loads a YAML configuration file and returns a Config object
140// Deprecated: Use LoadFromSources for new code
141func LoadConfig(filename string) (*MappingConfig, error) {
142 return LoadFromSources(filename, nil)
Akron06d21f02025-06-04 14:36:07 +0200143}
144
145// applyDefaults sets default values for SDK and Server if they are empty
146func applyDefaults(config *MappingConfig) {
147 if config.SDK == "" {
148 config.SDK = defaultSDK
149 }
150 if config.Server == "" {
151 config.Server = defaultServer
152 }
Akrona8a66ce2025-06-05 10:50:17 +0200153 if config.Port == 0 {
154 config.Port = defaultPort
155 }
156 if config.LogLevel == "" {
157 config.LogLevel = defaultLogLevel
158 }
Akron06d21f02025-06-04 14:36:07 +0200159}
160
161// validateMappingLists validates a slice of mapping lists
162func validateMappingLists(lists []MappingList) error {
Akron57ee5582025-05-21 15:25:13 +0200163 // Validate the configuration
Akrona5d88142025-05-22 14:42:09 +0200164 seenIDs := make(map[string]bool)
Akron57ee5582025-05-21 15:25:13 +0200165 for i, list := range lists {
166 if list.ID == "" {
Akron06d21f02025-06-04 14:36:07 +0200167 return fmt.Errorf("mapping list at index %d is missing an ID", i)
Akron57ee5582025-05-21 15:25:13 +0200168 }
Akrona5d88142025-05-22 14:42:09 +0200169
170 // Check for duplicate IDs
171 if seenIDs[list.ID] {
Akron06d21f02025-06-04 14:36:07 +0200172 return fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
Akrona5d88142025-05-22 14:42:09 +0200173 }
174 seenIDs[list.ID] = true
175
Akron57ee5582025-05-21 15:25:13 +0200176 if len(list.Mappings) == 0 {
Akron06d21f02025-06-04 14:36:07 +0200177 return fmt.Errorf("mapping list '%s' has no mapping rules", list.ID)
Akron57ee5582025-05-21 15:25:13 +0200178 }
179
180 // Validate each mapping rule
181 for j, rule := range list.Mappings {
182 if rule == "" {
Akron06d21f02025-06-04 14:36:07 +0200183 return fmt.Errorf("mapping list '%s' rule at index %d is empty", list.ID, j)
Akron57ee5582025-05-21 15:25:13 +0200184 }
185 }
186 }
Akron06d21f02025-06-04 14:36:07 +0200187 return nil
Akron57ee5582025-05-21 15:25:13 +0200188}
189
190// ParseMappings parses all mapping rules in a list and returns a slice of parsed rules
191func (list *MappingList) ParseMappings() ([]*parser.MappingResult, error) {
192 // Create a grammar parser with the list's default foundries and layers
193 grammarParser, err := parser.NewGrammarParser("", "")
194 if err != nil {
195 return nil, fmt.Errorf("failed to create grammar parser: %w", err)
196 }
197
198 results := make([]*parser.MappingResult, len(list.Mappings))
199 for i, rule := range list.Mappings {
Akrona5d88142025-05-22 14:42:09 +0200200 // Check for empty rules first
201 if rule == "" {
202 return nil, fmt.Errorf("empty mapping rule at index %d in list '%s'", i, list.ID)
203 }
204
Akron57ee5582025-05-21 15:25:13 +0200205 // Parse the mapping rule
206 result, err := grammarParser.ParseMapping(string(rule))
207 if err != nil {
208 return nil, fmt.Errorf("failed to parse mapping rule %d in list '%s': %w", i, list.ID, err)
209 }
210
211 // Apply default foundries and layers if not specified in the rule
212 if list.FoundryA != "" {
213 applyDefaultFoundryAndLayer(result.Upper.Wrap, list.FoundryA, list.LayerA)
214 }
215 if list.FoundryB != "" {
216 applyDefaultFoundryAndLayer(result.Lower.Wrap, list.FoundryB, list.LayerB)
217 }
218
219 results[i] = result
220 }
221
222 return results, nil
223}
224
225// applyDefaultFoundryAndLayer recursively applies default foundry and layer to terms that don't have them specified
226func applyDefaultFoundryAndLayer(node ast.Node, defaultFoundry, defaultLayer string) {
227 switch n := node.(type) {
228 case *ast.Term:
229 if n.Foundry == "" {
230 n.Foundry = defaultFoundry
231 }
232 if n.Layer == "" {
233 n.Layer = defaultLayer
234 }
235 case *ast.TermGroup:
236 for _, op := range n.Operands {
237 applyDefaultFoundryAndLayer(op, defaultFoundry, defaultLayer)
238 }
239 }
240}