blob: 46d6639138b015d272aa3d9a796a0545c7212a23 [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 (
13 defaultServer = "https://korap.ids-mannheim.de/"
14 defaultSDK = "https://korap.ids-mannheim.de/js/korap-plugin-latest.js"
15)
16
Akron57ee5582025-05-21 15:25:13 +020017// MappingRule represents a single mapping rule in the configuration
18type MappingRule string
19
20// MappingList represents a list of mapping rules with metadata
21type MappingList struct {
22 ID string `yaml:"id"`
23 FoundryA string `yaml:"foundryA,omitempty"`
24 LayerA string `yaml:"layerA,omitempty"`
25 FoundryB string `yaml:"foundryB,omitempty"`
26 LayerB string `yaml:"layerB,omitempty"`
27 Mappings []MappingRule `yaml:"mappings"`
28}
29
Akron06d21f02025-06-04 14:36:07 +020030// MappingConfig represents the root configuration containing multiple mapping lists
31type MappingConfig struct {
32 SDK string `yaml:"sdk,omitempty"`
33 Server string `yaml:"server,omitempty"`
34 Lists []MappingList `yaml:"lists,omitempty"`
Akron57ee5582025-05-21 15:25:13 +020035}
36
Akrone1cff7c2025-06-04 18:43:32 +020037// LoadFromSources loads configuration from multiple sources and merges them:
38// - A main configuration file (optional) containing global settings and lists
39// - Individual mapping files (optional) containing single mapping lists each
40// At least one source must be provided
41func LoadFromSources(configFile string, mappingFiles []string) (*MappingConfig, error) {
42 var allLists []MappingList
43 var globalConfig MappingConfig
Akron57ee5582025-05-21 15:25:13 +020044
Akrone1cff7c2025-06-04 18:43:32 +020045 // Track seen IDs across all sources to detect duplicates
46 seenIDs := make(map[string]bool)
Akrona5d88142025-05-22 14:42:09 +020047
Akrone1cff7c2025-06-04 18:43:32 +020048 // Load main configuration file if provided
49 if configFile != "" {
50 data, err := os.ReadFile(configFile)
51 if err != nil {
52 return nil, fmt.Errorf("failed to read config file '%s': %w", configFile, err)
Akron06d21f02025-06-04 14:36:07 +020053 }
Akrone1cff7c2025-06-04 18:43:32 +020054
55 if len(data) == 0 {
56 return nil, fmt.Errorf("EOF: config file '%s' is empty", configFile)
57 }
58
59 // Try to unmarshal as new format first (object with optional sdk/server and lists)
60 if err := yaml.Unmarshal(data, &globalConfig); err == nil && len(globalConfig.Lists) > 0 {
61 // Successfully parsed as new format with lists field
62 for _, list := range globalConfig.Lists {
63 if seenIDs[list.ID] {
64 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
65 }
66 seenIDs[list.ID] = true
67 }
68 allLists = append(allLists, globalConfig.Lists...)
69 } else {
70 // Fall back to old format (direct list)
71 var lists []MappingList
72 if err := yaml.Unmarshal(data, &lists); err != nil {
73 return nil, fmt.Errorf("failed to parse YAML config file '%s': %w", configFile, err)
74 }
75
76 for _, list := range lists {
77 if seenIDs[list.ID] {
78 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
79 }
80 seenIDs[list.ID] = true
81 }
82 allLists = append(allLists, lists...)
83 // Clear the lists from globalConfig since we got them from the old format
84 globalConfig.Lists = nil
85 }
Akron06d21f02025-06-04 14:36:07 +020086 }
87
Akrone1cff7c2025-06-04 18:43:32 +020088 // Load individual mapping files
89 for _, file := range mappingFiles {
90 data, err := os.ReadFile(file)
91 if err != nil {
92 return nil, fmt.Errorf("failed to read mapping file '%s': %w", file, err)
93 }
94
95 if len(data) == 0 {
96 return nil, fmt.Errorf("EOF: mapping file '%s' is empty", file)
97 }
98
99 var list MappingList
100 if err := yaml.Unmarshal(data, &list); err != nil {
101 return nil, fmt.Errorf("failed to parse YAML mapping file '%s': %w", file, err)
102 }
103
104 if seenIDs[list.ID] {
105 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
106 }
107 seenIDs[list.ID] = true
108 allLists = append(allLists, list)
Akron57ee5582025-05-21 15:25:13 +0200109 }
110
Akrone1cff7c2025-06-04 18:43:32 +0200111 // Ensure we have at least some configuration
112 if len(allLists) == 0 {
113 return nil, fmt.Errorf("no mapping lists found: provide either a config file (-c) with lists or mapping files (-m)")
114 }
115
116 // Validate all mapping lists
117 if err := validateMappingLists(allLists); err != nil {
Akron06d21f02025-06-04 14:36:07 +0200118 return nil, err
119 }
120
Akrone1cff7c2025-06-04 18:43:32 +0200121 // Create final configuration
122 result := &MappingConfig{
123 SDK: globalConfig.SDK,
124 Server: globalConfig.Server,
125 Lists: allLists,
126 }
127
Akron06d21f02025-06-04 14:36:07 +0200128 // Apply defaults if not specified
Akrone1cff7c2025-06-04 18:43:32 +0200129 applyDefaults(result)
130
131 return result, nil
132}
133
134// LoadConfig loads a YAML configuration file and returns a Config object
135// Deprecated: Use LoadFromSources for new code
136func LoadConfig(filename string) (*MappingConfig, error) {
137 return LoadFromSources(filename, nil)
Akron06d21f02025-06-04 14:36:07 +0200138}
139
140// applyDefaults sets default values for SDK and Server if they are empty
141func applyDefaults(config *MappingConfig) {
142 if config.SDK == "" {
143 config.SDK = defaultSDK
144 }
145 if config.Server == "" {
146 config.Server = defaultServer
147 }
148}
149
150// validateMappingLists validates a slice of mapping lists
151func validateMappingLists(lists []MappingList) error {
Akron57ee5582025-05-21 15:25:13 +0200152 // Validate the configuration
Akrona5d88142025-05-22 14:42:09 +0200153 seenIDs := make(map[string]bool)
Akron57ee5582025-05-21 15:25:13 +0200154 for i, list := range lists {
155 if list.ID == "" {
Akron06d21f02025-06-04 14:36:07 +0200156 return fmt.Errorf("mapping list at index %d is missing an ID", i)
Akron57ee5582025-05-21 15:25:13 +0200157 }
Akrona5d88142025-05-22 14:42:09 +0200158
159 // Check for duplicate IDs
160 if seenIDs[list.ID] {
Akron06d21f02025-06-04 14:36:07 +0200161 return fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
Akrona5d88142025-05-22 14:42:09 +0200162 }
163 seenIDs[list.ID] = true
164
Akron57ee5582025-05-21 15:25:13 +0200165 if len(list.Mappings) == 0 {
Akron06d21f02025-06-04 14:36:07 +0200166 return fmt.Errorf("mapping list '%s' has no mapping rules", list.ID)
Akron57ee5582025-05-21 15:25:13 +0200167 }
168
169 // Validate each mapping rule
170 for j, rule := range list.Mappings {
171 if rule == "" {
Akron06d21f02025-06-04 14:36:07 +0200172 return fmt.Errorf("mapping list '%s' rule at index %d is empty", list.ID, j)
Akron57ee5582025-05-21 15:25:13 +0200173 }
174 }
175 }
Akron06d21f02025-06-04 14:36:07 +0200176 return nil
Akron57ee5582025-05-21 15:25:13 +0200177}
178
179// ParseMappings parses all mapping rules in a list and returns a slice of parsed rules
180func (list *MappingList) ParseMappings() ([]*parser.MappingResult, error) {
181 // Create a grammar parser with the list's default foundries and layers
182 grammarParser, err := parser.NewGrammarParser("", "")
183 if err != nil {
184 return nil, fmt.Errorf("failed to create grammar parser: %w", err)
185 }
186
187 results := make([]*parser.MappingResult, len(list.Mappings))
188 for i, rule := range list.Mappings {
Akrona5d88142025-05-22 14:42:09 +0200189 // Check for empty rules first
190 if rule == "" {
191 return nil, fmt.Errorf("empty mapping rule at index %d in list '%s'", i, list.ID)
192 }
193
Akron57ee5582025-05-21 15:25:13 +0200194 // Parse the mapping rule
195 result, err := grammarParser.ParseMapping(string(rule))
196 if err != nil {
197 return nil, fmt.Errorf("failed to parse mapping rule %d in list '%s': %w", i, list.ID, err)
198 }
199
200 // Apply default foundries and layers if not specified in the rule
201 if list.FoundryA != "" {
202 applyDefaultFoundryAndLayer(result.Upper.Wrap, list.FoundryA, list.LayerA)
203 }
204 if list.FoundryB != "" {
205 applyDefaultFoundryAndLayer(result.Lower.Wrap, list.FoundryB, list.LayerB)
206 }
207
208 results[i] = result
209 }
210
211 return results, nil
212}
213
214// applyDefaultFoundryAndLayer recursively applies default foundry and layer to terms that don't have them specified
215func applyDefaultFoundryAndLayer(node ast.Node, defaultFoundry, defaultLayer string) {
216 switch n := node.(type) {
217 case *ast.Term:
218 if n.Foundry == "" {
219 n.Foundry = defaultFoundry
220 }
221 if n.Layer == "" {
222 n.Layer = defaultLayer
223 }
224 case *ast.TermGroup:
225 for _, op := range n.Operands {
226 applyDefaultFoundryAndLayer(op, defaultFoundry, defaultLayer)
227 }
228 }
229}