| package config |
| |
| import ( |
| "fmt" |
| "os" |
| |
| "github.com/KorAP/Koral-Mapper/ast" |
| "github.com/KorAP/Koral-Mapper/parser" |
| "github.com/rs/zerolog/log" |
| "gopkg.in/yaml.v3" |
| ) |
| |
| const ( |
| defaultServer = "https://korap.ids-mannheim.de/" |
| defaultSDK = "https://korap.ids-mannheim.de/js/korap-plugin-latest.js" |
| defaultServiceURL = "https://korap.ids-mannheim.de/plugin/koralmapper" |
| defaultPort = 5725 |
| defaultLogLevel = "warn" |
| ) |
| |
| // MappingRule represents a single mapping rule in the configuration |
| type MappingRule string |
| |
| // MappingList represents a list of mapping rules with metadata |
| type MappingList struct { |
| ID string `yaml:"id"` |
| Description string `yaml:"desc,omitempty"` |
| FoundryA string `yaml:"foundryA,omitempty"` |
| LayerA string `yaml:"layerA,omitempty"` |
| FoundryB string `yaml:"foundryB,omitempty"` |
| LayerB string `yaml:"layerB,omitempty"` |
| Mappings []MappingRule `yaml:"mappings"` |
| } |
| |
| // MappingConfig represents the root configuration containing multiple mapping lists |
| type MappingConfig struct { |
| SDK string `yaml:"sdk,omitempty"` |
| Server string `yaml:"server,omitempty"` |
| ServiceURL string `yaml:"serviceURL,omitempty"` |
| Port int `yaml:"port,omitempty"` |
| LogLevel string `yaml:"loglevel,omitempty"` |
| Lists []MappingList `yaml:"lists,omitempty"` |
| } |
| |
| // LoadFromSources loads configuration from multiple sources and merges them: |
| // - A main configuration file (optional) containing global settings and lists |
| // - Individual mapping files (optional) containing single mapping lists each |
| // At least one source must be provided |
| func LoadFromSources(configFile string, mappingFiles []string) (*MappingConfig, error) { |
| var allLists []MappingList |
| var globalConfig MappingConfig |
| |
| // Track seen IDs across all sources to detect duplicates |
| seenIDs := make(map[string]bool) |
| |
| // Load main configuration file if provided |
| if configFile != "" { |
| data, err := os.ReadFile(configFile) |
| if err != nil { |
| return nil, fmt.Errorf("failed to read config file '%s': %w", configFile, err) |
| } |
| |
| if len(data) == 0 { |
| return nil, fmt.Errorf("EOF: config file '%s' is empty", configFile) |
| } |
| |
| // Try to unmarshal as new format first (object with optional sdk/server and lists) |
| if err := yaml.Unmarshal(data, &globalConfig); err == nil { |
| // Successfully parsed as new format - accept it regardless of whether it has lists |
| for _, list := range globalConfig.Lists { |
| if seenIDs[list.ID] { |
| return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID) |
| } |
| seenIDs[list.ID] = true |
| } |
| allLists = append(allLists, globalConfig.Lists...) |
| } else { |
| // Fall back to old format (direct list) |
| var lists []MappingList |
| if err := yaml.Unmarshal(data, &lists); err != nil { |
| return nil, fmt.Errorf("failed to parse YAML config file '%s': %w", configFile, err) |
| } |
| |
| for _, list := range lists { |
| if seenIDs[list.ID] { |
| return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID) |
| } |
| seenIDs[list.ID] = true |
| } |
| allLists = append(allLists, lists...) |
| // Clear the lists from globalConfig since we got them from the old format |
| globalConfig.Lists = nil |
| } |
| } |
| |
| // Load individual mapping files |
| for _, file := range mappingFiles { |
| data, err := os.ReadFile(file) |
| if err != nil { |
| log.Error().Err(err).Str("file", file).Msg("Failed to read mapping file") |
| continue |
| } |
| |
| if len(data) == 0 { |
| log.Error().Err(err).Str("file", file).Msg("EOF: mapping file is empty") |
| continue |
| } |
| |
| var list MappingList |
| if err := yaml.Unmarshal(data, &list); err != nil { |
| log.Error().Err(err).Str("file", file).Msg("Failed to parse YAML mapping file") |
| continue |
| } |
| |
| if seenIDs[list.ID] { |
| log.Error().Err(err).Str("file", file).Str("list-id", list.ID).Msg("Duplicate mapping list ID found") |
| continue |
| } |
| seenIDs[list.ID] = true |
| allLists = append(allLists, list) |
| } |
| |
| // Ensure we have at least some configuration |
| if len(allLists) == 0 { |
| return nil, fmt.Errorf("no mapping lists found: provide either a config file (-c) with lists or mapping files (-m)") |
| } |
| |
| // Validate all mapping lists (skip duplicate ID check since we already did it) |
| if err := validateMappingLists(allLists); err != nil { |
| return nil, err |
| } |
| |
| // Create final configuration |
| result := &MappingConfig{ |
| SDK: globalConfig.SDK, |
| Server: globalConfig.Server, |
| ServiceURL: globalConfig.ServiceURL, |
| Port: globalConfig.Port, |
| LogLevel: globalConfig.LogLevel, |
| Lists: allLists, |
| } |
| |
| // Apply defaults if not specified |
| ApplyDefaults(result) |
| |
| return result, nil |
| } |
| |
| // ApplyDefaults sets default values for configuration fields if they are empty |
| func ApplyDefaults(config *MappingConfig) { |
| defaults := map[*string]string{ |
| &config.SDK: defaultSDK, |
| &config.Server: defaultServer, |
| &config.ServiceURL: defaultServiceURL, |
| &config.LogLevel: defaultLogLevel, |
| } |
| |
| for field, defaultValue := range defaults { |
| if *field == "" { |
| *field = defaultValue |
| } |
| } |
| |
| if config.Port == 0 { |
| config.Port = defaultPort |
| } |
| } |
| |
| // validateMappingLists validates a slice of mapping lists (without duplicate ID checking) |
| func validateMappingLists(lists []MappingList) error { |
| for i, list := range lists { |
| if list.ID == "" { |
| return fmt.Errorf("mapping list at index %d is missing an ID", i) |
| } |
| |
| if len(list.Mappings) == 0 { |
| return fmt.Errorf("mapping list '%s' has no mapping rules", list.ID) |
| } |
| |
| // Validate each mapping rule |
| for j, rule := range list.Mappings { |
| if rule == "" { |
| return fmt.Errorf("mapping list '%s' rule at index %d is empty", list.ID, j) |
| } |
| } |
| } |
| return nil |
| } |
| |
| // ParseMappings parses all mapping rules in a list and returns a slice of parsed rules |
| func (list *MappingList) ParseMappings() ([]*parser.MappingResult, error) { |
| // Create a grammar parser with the list's default foundries and layers |
| grammarParser, err := parser.NewGrammarParser("", "") |
| if err != nil { |
| return nil, fmt.Errorf("failed to create grammar parser: %w", err) |
| } |
| |
| results := make([]*parser.MappingResult, len(list.Mappings)) |
| for i, rule := range list.Mappings { |
| // Check for empty rules first |
| if rule == "" { |
| return nil, fmt.Errorf("empty mapping rule at index %d in list '%s'", i, list.ID) |
| } |
| |
| // Parse the mapping rule |
| result, err := grammarParser.ParseMapping(string(rule)) |
| if err != nil { |
| return nil, fmt.Errorf("failed to parse mapping rule %d in list '%s': %w", i, list.ID, err) |
| } |
| |
| // Apply default foundries and layers if not specified in the rule |
| if list.FoundryA != "" { |
| applyDefaultFoundryAndLayer(result.Upper.Wrap, list.FoundryA, list.LayerA) |
| } |
| if list.FoundryB != "" { |
| applyDefaultFoundryAndLayer(result.Lower.Wrap, list.FoundryB, list.LayerB) |
| } |
| |
| results[i] = result |
| } |
| |
| return results, nil |
| } |
| |
| // applyDefaultFoundryAndLayer recursively applies default foundry and layer to terms that don't have them specified |
| func applyDefaultFoundryAndLayer(node ast.Node, defaultFoundry, defaultLayer string) { |
| switch n := node.(type) { |
| case *ast.Term: |
| if n.Foundry == "" && defaultFoundry != "" { |
| n.Foundry = defaultFoundry |
| } |
| if n.Layer == "" && defaultLayer != "" { |
| n.Layer = defaultLayer |
| } |
| case *ast.TermGroup: |
| for _, op := range n.Operands { |
| applyDefaultFoundryAndLayer(op, defaultFoundry, defaultLayer) |
| } |
| } |
| } |