blob: ff0db3165ac757a2a3122431258130e800cce970 [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 {
24 ID string `yaml:"id"`
25 FoundryA string `yaml:"foundryA,omitempty"`
26 LayerA string `yaml:"layerA,omitempty"`
27 FoundryB string `yaml:"foundryB,omitempty"`
28 LayerB string `yaml:"layerB,omitempty"`
29 Mappings []MappingRule `yaml:"mappings"`
30}
31
Akron06d21f02025-06-04 14:36:07 +020032// MappingConfig represents the root configuration containing multiple mapping lists
33type MappingConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020034 SDK string `yaml:"sdk,omitempty"`
35 Server string `yaml:"server,omitempty"`
36 Port int `yaml:"port,omitempty"`
37 LogLevel string `yaml:"loglevel,omitempty"`
38 Lists []MappingList `yaml:"lists,omitempty"`
Akron57ee5582025-05-21 15:25:13 +020039}
40
Akrone1cff7c2025-06-04 18:43:32 +020041// LoadFromSources loads configuration from multiple sources and merges them:
42// - A main configuration file (optional) containing global settings and lists
43// - Individual mapping files (optional) containing single mapping lists each
44// At least one source must be provided
45func LoadFromSources(configFile string, mappingFiles []string) (*MappingConfig, error) {
46 var allLists []MappingList
47 var globalConfig MappingConfig
Akron57ee5582025-05-21 15:25:13 +020048
Akrone1cff7c2025-06-04 18:43:32 +020049 // Track seen IDs across all sources to detect duplicates
50 seenIDs := make(map[string]bool)
Akrona5d88142025-05-22 14:42:09 +020051
Akrone1cff7c2025-06-04 18:43:32 +020052 // Load main configuration file if provided
53 if configFile != "" {
54 data, err := os.ReadFile(configFile)
55 if err != nil {
56 return nil, fmt.Errorf("failed to read config file '%s': %w", configFile, err)
Akron06d21f02025-06-04 14:36:07 +020057 }
Akrone1cff7c2025-06-04 18:43:32 +020058
59 if len(data) == 0 {
60 return nil, fmt.Errorf("EOF: config file '%s' is empty", configFile)
61 }
62
63 // Try to unmarshal as new format first (object with optional sdk/server and lists)
64 if err := yaml.Unmarshal(data, &globalConfig); err == nil && len(globalConfig.Lists) > 0 {
65 // Successfully parsed as new format with lists field
66 for _, list := range globalConfig.Lists {
67 if seenIDs[list.ID] {
68 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
69 }
70 seenIDs[list.ID] = true
71 }
72 allLists = append(allLists, globalConfig.Lists...)
73 } else {
74 // Fall back to old format (direct list)
75 var lists []MappingList
76 if err := yaml.Unmarshal(data, &lists); err != nil {
77 return nil, fmt.Errorf("failed to parse YAML config file '%s': %w", configFile, err)
78 }
79
80 for _, list := range lists {
81 if seenIDs[list.ID] {
82 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
83 }
84 seenIDs[list.ID] = true
85 }
86 allLists = append(allLists, lists...)
87 // Clear the lists from globalConfig since we got them from the old format
88 globalConfig.Lists = nil
89 }
Akron06d21f02025-06-04 14:36:07 +020090 }
91
Akrone1cff7c2025-06-04 18:43:32 +020092 // Load individual mapping files
93 for _, file := range mappingFiles {
94 data, err := os.ReadFile(file)
95 if err != nil {
96 return nil, fmt.Errorf("failed to read mapping file '%s': %w", file, err)
97 }
98
99 if len(data) == 0 {
100 return nil, fmt.Errorf("EOF: mapping file '%s' is empty", file)
101 }
102
103 var list MappingList
104 if err := yaml.Unmarshal(data, &list); err != nil {
105 return nil, fmt.Errorf("failed to parse YAML mapping file '%s': %w", file, err)
106 }
107
108 if seenIDs[list.ID] {
109 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
110 }
111 seenIDs[list.ID] = true
112 allLists = append(allLists, list)
Akron57ee5582025-05-21 15:25:13 +0200113 }
114
Akrone1cff7c2025-06-04 18:43:32 +0200115 // Ensure we have at least some configuration
116 if len(allLists) == 0 {
117 return nil, fmt.Errorf("no mapping lists found: provide either a config file (-c) with lists or mapping files (-m)")
118 }
119
120 // Validate all mapping lists
121 if err := validateMappingLists(allLists); err != nil {
Akron06d21f02025-06-04 14:36:07 +0200122 return nil, err
123 }
124
Akrone1cff7c2025-06-04 18:43:32 +0200125 // Create final configuration
126 result := &MappingConfig{
127 SDK: globalConfig.SDK,
128 Server: globalConfig.Server,
129 Lists: allLists,
130 }
131
Akron06d21f02025-06-04 14:36:07 +0200132 // Apply defaults if not specified
Akrone1cff7c2025-06-04 18:43:32 +0200133 applyDefaults(result)
134
135 return result, nil
136}
137
138// LoadConfig loads a YAML configuration file and returns a Config object
139// Deprecated: Use LoadFromSources for new code
140func LoadConfig(filename string) (*MappingConfig, error) {
141 return LoadFromSources(filename, nil)
Akron06d21f02025-06-04 14:36:07 +0200142}
143
144// applyDefaults sets default values for SDK and Server if they are empty
145func applyDefaults(config *MappingConfig) {
146 if config.SDK == "" {
147 config.SDK = defaultSDK
148 }
149 if config.Server == "" {
150 config.Server = defaultServer
151 }
Akrona8a66ce2025-06-05 10:50:17 +0200152 if config.Port == 0 {
153 config.Port = defaultPort
154 }
155 if config.LogLevel == "" {
156 config.LogLevel = defaultLogLevel
157 }
Akron06d21f02025-06-04 14:36:07 +0200158}
159
160// validateMappingLists validates a slice of mapping lists
161func validateMappingLists(lists []MappingList) error {
Akron57ee5582025-05-21 15:25:13 +0200162 // Validate the configuration
Akrona5d88142025-05-22 14:42:09 +0200163 seenIDs := make(map[string]bool)
Akron57ee5582025-05-21 15:25:13 +0200164 for i, list := range lists {
165 if list.ID == "" {
Akron06d21f02025-06-04 14:36:07 +0200166 return fmt.Errorf("mapping list at index %d is missing an ID", i)
Akron57ee5582025-05-21 15:25:13 +0200167 }
Akrona5d88142025-05-22 14:42:09 +0200168
169 // Check for duplicate IDs
170 if seenIDs[list.ID] {
Akron06d21f02025-06-04 14:36:07 +0200171 return fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
Akrona5d88142025-05-22 14:42:09 +0200172 }
173 seenIDs[list.ID] = true
174
Akron57ee5582025-05-21 15:25:13 +0200175 if len(list.Mappings) == 0 {
Akron06d21f02025-06-04 14:36:07 +0200176 return fmt.Errorf("mapping list '%s' has no mapping rules", list.ID)
Akron57ee5582025-05-21 15:25:13 +0200177 }
178
179 // Validate each mapping rule
180 for j, rule := range list.Mappings {
181 if rule == "" {
Akron06d21f02025-06-04 14:36:07 +0200182 return fmt.Errorf("mapping list '%s' rule at index %d is empty", list.ID, j)
Akron57ee5582025-05-21 15:25:13 +0200183 }
184 }
185 }
Akron06d21f02025-06-04 14:36:07 +0200186 return nil
Akron57ee5582025-05-21 15:25:13 +0200187}
188
189// ParseMappings parses all mapping rules in a list and returns a slice of parsed rules
190func (list *MappingList) ParseMappings() ([]*parser.MappingResult, error) {
191 // Create a grammar parser with the list's default foundries and layers
192 grammarParser, err := parser.NewGrammarParser("", "")
193 if err != nil {
194 return nil, fmt.Errorf("failed to create grammar parser: %w", err)
195 }
196
197 results := make([]*parser.MappingResult, len(list.Mappings))
198 for i, rule := range list.Mappings {
Akrona5d88142025-05-22 14:42:09 +0200199 // Check for empty rules first
200 if rule == "" {
201 return nil, fmt.Errorf("empty mapping rule at index %d in list '%s'", i, list.ID)
202 }
203
Akron57ee5582025-05-21 15:25:13 +0200204 // Parse the mapping rule
205 result, err := grammarParser.ParseMapping(string(rule))
206 if err != nil {
207 return nil, fmt.Errorf("failed to parse mapping rule %d in list '%s': %w", i, list.ID, err)
208 }
209
210 // Apply default foundries and layers if not specified in the rule
211 if list.FoundryA != "" {
212 applyDefaultFoundryAndLayer(result.Upper.Wrap, list.FoundryA, list.LayerA)
213 }
214 if list.FoundryB != "" {
215 applyDefaultFoundryAndLayer(result.Lower.Wrap, list.FoundryB, list.LayerB)
216 }
217
218 results[i] = result
219 }
220
221 return results, nil
222}
223
224// applyDefaultFoundryAndLayer recursively applies default foundry and layer to terms that don't have them specified
225func applyDefaultFoundryAndLayer(node ast.Node, defaultFoundry, defaultLayer string) {
226 switch n := node.(type) {
227 case *ast.Term:
228 if n.Foundry == "" {
229 n.Foundry = defaultFoundry
230 }
231 if n.Layer == "" {
232 n.Layer = defaultLayer
233 }
234 case *ast.TermGroup:
235 for _, op := range n.Operands {
236 applyDefaultFoundryAndLayer(op, defaultFoundry, defaultLayer)
237 }
238 }
239}