blob: 11768761493239e955ba7c550b93f73c2b341430 [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 (
Akron2ac2ec02025-06-05 15:26:42 +020013 defaultServer = "https://korap.ids-mannheim.de/"
14 defaultSDK = "https://korap.ids-mannheim.de/js/korap-plugin-latest.js"
15 defaultServiceURL = "https://korap.ids-mannheim.de/plugin/termmapper"
Akron14c13a52025-06-06 15:36:23 +020016 defaultPort = 5725
Akron2ac2ec02025-06-05 15:26:42 +020017 defaultLogLevel = "warn"
Akron06d21f02025-06-04 14:36:07 +020018)
19
Akron57ee5582025-05-21 15:25:13 +020020// MappingRule represents a single mapping rule in the configuration
21type MappingRule string
22
23// MappingList represents a list of mapping rules with metadata
24type MappingList struct {
Akrondab27112025-06-05 13:52:43 +020025 ID string `yaml:"id"`
26 Description string `yaml:"desc,omitempty"`
27 FoundryA string `yaml:"foundryA,omitempty"`
28 LayerA string `yaml:"layerA,omitempty"`
29 FoundryB string `yaml:"foundryB,omitempty"`
30 LayerB string `yaml:"layerB,omitempty"`
31 Mappings []MappingRule `yaml:"mappings"`
Akron57ee5582025-05-21 15:25:13 +020032}
33
Akron06d21f02025-06-04 14:36:07 +020034// MappingConfig represents the root configuration containing multiple mapping lists
35type MappingConfig struct {
Akron2ac2ec02025-06-05 15:26:42 +020036 SDK string `yaml:"sdk,omitempty"`
37 Server string `yaml:"server,omitempty"`
38 ServiceURL string `yaml:"serviceURL,omitempty"`
39 Port int `yaml:"port,omitempty"`
40 LogLevel string `yaml:"loglevel,omitempty"`
41 Lists []MappingList `yaml:"lists,omitempty"`
Akron57ee5582025-05-21 15:25:13 +020042}
43
Akrone1cff7c2025-06-04 18:43:32 +020044// LoadFromSources loads configuration from multiple sources and merges them:
45// - A main configuration file (optional) containing global settings and lists
46// - Individual mapping files (optional) containing single mapping lists each
47// At least one source must be provided
48func LoadFromSources(configFile string, mappingFiles []string) (*MappingConfig, error) {
49 var allLists []MappingList
50 var globalConfig MappingConfig
Akron57ee5582025-05-21 15:25:13 +020051
Akrone1cff7c2025-06-04 18:43:32 +020052 // Track seen IDs across all sources to detect duplicates
53 seenIDs := make(map[string]bool)
Akrona5d88142025-05-22 14:42:09 +020054
Akrone1cff7c2025-06-04 18:43:32 +020055 // Load main configuration file if provided
56 if configFile != "" {
57 data, err := os.ReadFile(configFile)
58 if err != nil {
59 return nil, fmt.Errorf("failed to read config file '%s': %w", configFile, err)
Akron06d21f02025-06-04 14:36:07 +020060 }
Akrone1cff7c2025-06-04 18:43:32 +020061
62 if len(data) == 0 {
63 return nil, fmt.Errorf("EOF: config file '%s' is empty", configFile)
64 }
65
66 // Try to unmarshal as new format first (object with optional sdk/server and lists)
Akron813780f2025-06-05 15:44:28 +020067 if err := yaml.Unmarshal(data, &globalConfig); err == nil {
68 // Successfully parsed as new format - accept it regardless of whether it has lists
Akrone1cff7c2025-06-04 18:43:32 +020069 for _, list := range globalConfig.Lists {
70 if seenIDs[list.ID] {
71 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
72 }
73 seenIDs[list.ID] = true
74 }
75 allLists = append(allLists, globalConfig.Lists...)
76 } else {
77 // Fall back to old format (direct list)
78 var lists []MappingList
79 if err := yaml.Unmarshal(data, &lists); err != nil {
80 return nil, fmt.Errorf("failed to parse YAML config file '%s': %w", configFile, err)
81 }
82
83 for _, list := range lists {
84 if seenIDs[list.ID] {
85 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
86 }
87 seenIDs[list.ID] = true
88 }
89 allLists = append(allLists, lists...)
90 // Clear the lists from globalConfig since we got them from the old format
91 globalConfig.Lists = nil
92 }
Akron06d21f02025-06-04 14:36:07 +020093 }
94
Akrone1cff7c2025-06-04 18:43:32 +020095 // Load individual mapping files
96 for _, file := range mappingFiles {
97 data, err := os.ReadFile(file)
98 if err != nil {
99 return nil, fmt.Errorf("failed to read mapping file '%s': %w", file, err)
100 }
101
102 if len(data) == 0 {
103 return nil, fmt.Errorf("EOF: mapping file '%s' is empty", file)
104 }
105
106 var list MappingList
107 if err := yaml.Unmarshal(data, &list); err != nil {
108 return nil, fmt.Errorf("failed to parse YAML mapping file '%s': %w", file, err)
109 }
110
111 if seenIDs[list.ID] {
112 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
113 }
114 seenIDs[list.ID] = true
115 allLists = append(allLists, list)
Akron57ee5582025-05-21 15:25:13 +0200116 }
117
Akrone1cff7c2025-06-04 18:43:32 +0200118 // Ensure we have at least some configuration
119 if len(allLists) == 0 {
120 return nil, fmt.Errorf("no mapping lists found: provide either a config file (-c) with lists or mapping files (-m)")
121 }
122
123 // Validate all mapping lists
124 if err := validateMappingLists(allLists); err != nil {
Akron06d21f02025-06-04 14:36:07 +0200125 return nil, err
126 }
127
Akrone1cff7c2025-06-04 18:43:32 +0200128 // Create final configuration
129 result := &MappingConfig{
Akron2ac2ec02025-06-05 15:26:42 +0200130 SDK: globalConfig.SDK,
131 Server: globalConfig.Server,
132 ServiceURL: globalConfig.ServiceURL,
133 Port: globalConfig.Port,
134 LogLevel: globalConfig.LogLevel,
135 Lists: allLists,
Akrone1cff7c2025-06-04 18:43:32 +0200136 }
137
Akron06d21f02025-06-04 14:36:07 +0200138 // Apply defaults if not specified
Akron2ac2ec02025-06-05 15:26:42 +0200139 ApplyDefaults(result)
Akrone1cff7c2025-06-04 18:43:32 +0200140
141 return result, nil
142}
143
144// LoadConfig loads a YAML configuration file and returns a Config object
145// Deprecated: Use LoadFromSources for new code
146func LoadConfig(filename string) (*MappingConfig, error) {
147 return LoadFromSources(filename, nil)
Akron06d21f02025-06-04 14:36:07 +0200148}
149
Akron2ac2ec02025-06-05 15:26:42 +0200150// ApplyDefaults sets default values for SDK and Server if they are empty
151func ApplyDefaults(config *MappingConfig) {
Akron06d21f02025-06-04 14:36:07 +0200152 if config.SDK == "" {
153 config.SDK = defaultSDK
154 }
155 if config.Server == "" {
156 config.Server = defaultServer
157 }
Akron2ac2ec02025-06-05 15:26:42 +0200158 if config.ServiceURL == "" {
159 config.ServiceURL = defaultServiceURL
160 }
Akrona8a66ce2025-06-05 10:50:17 +0200161 if config.Port == 0 {
162 config.Port = defaultPort
163 }
164 if config.LogLevel == "" {
165 config.LogLevel = defaultLogLevel
166 }
Akron06d21f02025-06-04 14:36:07 +0200167}
168
169// validateMappingLists validates a slice of mapping lists
170func validateMappingLists(lists []MappingList) error {
Akron57ee5582025-05-21 15:25:13 +0200171 // Validate the configuration
Akrona5d88142025-05-22 14:42:09 +0200172 seenIDs := make(map[string]bool)
Akron57ee5582025-05-21 15:25:13 +0200173 for i, list := range lists {
174 if list.ID == "" {
Akron06d21f02025-06-04 14:36:07 +0200175 return fmt.Errorf("mapping list at index %d is missing an ID", i)
Akron57ee5582025-05-21 15:25:13 +0200176 }
Akrona5d88142025-05-22 14:42:09 +0200177
178 // Check for duplicate IDs
179 if seenIDs[list.ID] {
Akron06d21f02025-06-04 14:36:07 +0200180 return fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
Akrona5d88142025-05-22 14:42:09 +0200181 }
182 seenIDs[list.ID] = true
183
Akron57ee5582025-05-21 15:25:13 +0200184 if len(list.Mappings) == 0 {
Akron06d21f02025-06-04 14:36:07 +0200185 return fmt.Errorf("mapping list '%s' has no mapping rules", list.ID)
Akron57ee5582025-05-21 15:25:13 +0200186 }
187
188 // Validate each mapping rule
189 for j, rule := range list.Mappings {
190 if rule == "" {
Akron06d21f02025-06-04 14:36:07 +0200191 return fmt.Errorf("mapping list '%s' rule at index %d is empty", list.ID, j)
Akron57ee5582025-05-21 15:25:13 +0200192 }
193 }
194 }
Akron06d21f02025-06-04 14:36:07 +0200195 return nil
Akron57ee5582025-05-21 15:25:13 +0200196}
197
198// ParseMappings parses all mapping rules in a list and returns a slice of parsed rules
199func (list *MappingList) ParseMappings() ([]*parser.MappingResult, error) {
200 // Create a grammar parser with the list's default foundries and layers
201 grammarParser, err := parser.NewGrammarParser("", "")
202 if err != nil {
203 return nil, fmt.Errorf("failed to create grammar parser: %w", err)
204 }
205
206 results := make([]*parser.MappingResult, len(list.Mappings))
207 for i, rule := range list.Mappings {
Akrona5d88142025-05-22 14:42:09 +0200208 // Check for empty rules first
209 if rule == "" {
210 return nil, fmt.Errorf("empty mapping rule at index %d in list '%s'", i, list.ID)
211 }
212
Akron57ee5582025-05-21 15:25:13 +0200213 // Parse the mapping rule
214 result, err := grammarParser.ParseMapping(string(rule))
215 if err != nil {
216 return nil, fmt.Errorf("failed to parse mapping rule %d in list '%s': %w", i, list.ID, err)
217 }
218
219 // Apply default foundries and layers if not specified in the rule
220 if list.FoundryA != "" {
221 applyDefaultFoundryAndLayer(result.Upper.Wrap, list.FoundryA, list.LayerA)
222 }
223 if list.FoundryB != "" {
224 applyDefaultFoundryAndLayer(result.Lower.Wrap, list.FoundryB, list.LayerB)
225 }
226
227 results[i] = result
228 }
229
230 return results, nil
231}
232
233// applyDefaultFoundryAndLayer recursively applies default foundry and layer to terms that don't have them specified
234func applyDefaultFoundryAndLayer(node ast.Node, defaultFoundry, defaultLayer string) {
235 switch n := node.(type) {
236 case *ast.Term:
237 if n.Foundry == "" {
238 n.Foundry = defaultFoundry
239 }
240 if n.Layer == "" {
241 n.Layer = defaultLayer
242 }
243 case *ast.TermGroup:
244 for _, op := range n.Operands {
245 applyDefaultFoundryAndLayer(op, defaultFoundry, defaultLayer)
246 }
247 }
248}