blob: e024c7673d794cab76e16ee53eca7adbc0c43bba [file] [log] [blame]
Akron57ee5582025-05-21 15:25:13 +02001package config
2
3import (
4 "fmt"
Akrondaca3142026-05-21 13:00:45 +02005 "net/url"
Akron57ee5582025-05-21 15:25:13 +02006 "os"
Akroned787d02026-05-20 12:31:07 +02007 "path/filepath"
Akronf98ba282026-02-24 11:13:30 +01008 "strconv"
Akroned787d02026-05-20 12:31:07 +02009 "strings"
Akron57ee5582025-05-21 15:25:13 +020010
Akron2ef703c2025-07-03 15:57:42 +020011 "github.com/KorAP/Koral-Mapper/ast"
12 "github.com/KorAP/Koral-Mapper/parser"
Akron7e8da932025-07-01 11:56:46 +020013 "github.com/rs/zerolog/log"
Akron57ee5582025-05-21 15:25:13 +020014 "gopkg.in/yaml.v3"
15)
16
Akron06d21f02025-06-04 14:36:07 +020017const (
Akron2ac2ec02025-06-05 15:26:42 +020018 defaultServer = "https://korap.ids-mannheim.de/"
19 defaultSDK = "https://korap.ids-mannheim.de/js/korap-plugin-latest.js"
Akron43fb1022026-02-20 11:38:49 +010020 defaultStylesheet = "https://korap.ids-mannheim.de/css/kalamar-plugin-latest.css"
Akron2ef703c2025-07-03 15:57:42 +020021 defaultServiceURL = "https://korap.ids-mannheim.de/plugin/koralmapper"
Akron43fb1022026-02-20 11:38:49 +010022 defaultCookieName = "km-config"
Akron14c13a52025-06-06 15:36:23 +020023 defaultPort = 5725
Akronf1ca8822026-05-20 15:44:00 +020024 defaultLogLevel = "warn"
25 defaultRateLimit = 100
Akron06d21f02025-06-04 14:36:07 +020026)
27
Akron57ee5582025-05-21 15:25:13 +020028// MappingRule represents a single mapping rule in the configuration
29type MappingRule string
30
31// MappingList represents a list of mapping rules with metadata
32type MappingList struct {
Akrondab27112025-06-05 13:52:43 +020033 ID string `yaml:"id"`
Akron2f93c582026-02-19 16:49:13 +010034 Type string `yaml:"type,omitempty"` // "annotation" (default) or "corpus"
Akrondab27112025-06-05 13:52:43 +020035 Description string `yaml:"desc,omitempty"`
36 FoundryA string `yaml:"foundryA,omitempty"`
37 LayerA string `yaml:"layerA,omitempty"`
38 FoundryB string `yaml:"foundryB,omitempty"`
39 LayerB string `yaml:"layerB,omitempty"`
Akrona67de8f2026-02-23 17:54:26 +010040 FieldA string `yaml:"fieldA,omitempty"`
41 FieldB string `yaml:"fieldB,omitempty"`
Akronf7bba072026-05-21 12:36:19 +020042 Rewrites *bool `yaml:"rewrites,omitempty"`
Akrondab27112025-06-05 13:52:43 +020043 Mappings []MappingRule `yaml:"mappings"`
Akron57ee5582025-05-21 15:25:13 +020044}
45
Akron2f93c582026-02-19 16:49:13 +010046// IsCorpus returns true if the mapping list type is "corpus".
47func (list *MappingList) IsCorpus() bool {
48 return list.Type == "corpus"
49}
50
Akronf7bba072026-05-21 12:36:19 +020051// EffectiveRewrites returns the resolved rewrites setting for this list.
52// If the list has an explicit per-list override, it is used; otherwise the
53// global default is returned.
54func (list *MappingList) EffectiveRewrites(globalDefault bool) bool {
55 if list.Rewrites != nil {
56 return *list.Rewrites
57 }
58 return globalDefault
59}
60
Akron2f93c582026-02-19 16:49:13 +010061// ParseCorpusMappings parses all mapping rules as corpus rules.
Akrona67de8f2026-02-23 17:54:26 +010062// Bare values (without key=) are always allowed and receive the default
63// field name from the mapping list header (FieldA/FieldB) when set.
Akron2f93c582026-02-19 16:49:13 +010064func (list *MappingList) ParseCorpusMappings() ([]*parser.CorpusMappingResult, error) {
65 corpusParser := parser.NewCorpusParser()
Akrona67de8f2026-02-23 17:54:26 +010066 corpusParser.AllowBareValues = true
67
Akron2f93c582026-02-19 16:49:13 +010068 results := make([]*parser.CorpusMappingResult, len(list.Mappings))
69 for i, rule := range list.Mappings {
70 if rule == "" {
71 return nil, fmt.Errorf("empty corpus mapping rule at index %d in list '%s'", i, list.ID)
72 }
73 result, err := corpusParser.ParseMapping(string(rule))
74 if err != nil {
75 return nil, fmt.Errorf("failed to parse corpus mapping rule %d in list '%s': %w", i, list.ID, err)
76 }
Akrona67de8f2026-02-23 17:54:26 +010077
78 if list.FieldA != "" {
79 applyDefaultCorpusKey(result.Upper, list.FieldA)
80 }
81 if list.FieldB != "" {
82 applyDefaultCorpusKey(result.Lower, list.FieldB)
83 }
84
Akron2f93c582026-02-19 16:49:13 +010085 results[i] = result
86 }
87 return results, nil
88}
89
Akrona67de8f2026-02-23 17:54:26 +010090// applyDefaultCorpusKey recursively fills in empty keys on CorpusField nodes.
91func applyDefaultCorpusKey(node parser.CorpusNode, defaultKey string) {
92 switch n := node.(type) {
93 case *parser.CorpusField:
94 if n.Key == "" {
95 n.Key = defaultKey
96 }
97 case *parser.CorpusGroup:
98 for _, op := range n.Operands {
99 applyDefaultCorpusKey(op, defaultKey)
100 }
101 }
102}
103
Akron06d21f02025-06-04 14:36:07 +0200104// MappingConfig represents the root configuration containing multiple mapping lists
105type MappingConfig struct {
Akronf1ca8822026-05-20 15:44:00 +0200106 SDK string `yaml:"sdk,omitempty"`
107 Stylesheet string `yaml:"stylesheet,omitempty"`
108 Server string `yaml:"server,omitempty"`
109 ServiceURL string `yaml:"serviceURL,omitempty"`
110 CookieName string `yaml:"cookieName,omitempty"`
111 BasePath string `yaml:"basePath,omitempty"` // restricts config file loading to this directory tree
Akron2d53b932026-07-15 12:12:54 +0200112 AllowOrigins []string `yaml:"allowOrigins,omitempty"`
Akronf1ca8822026-05-20 15:44:00 +0200113 Port int `yaml:"port,omitempty"`
114 LogLevel string `yaml:"loglevel,omitempty"`
115 RateLimit int `yaml:"rateLimit,omitempty"` // max requests per minute per IP (0 = use default 100)
Akronf7bba072026-05-21 12:36:19 +0200116 Rewrites bool `yaml:"rewrites,omitempty"` // global default for koral:rewrite annotations
Akronf1ca8822026-05-20 15:44:00 +0200117 Lists []MappingList `yaml:"lists,omitempty"`
Akron57ee5582025-05-21 15:25:13 +0200118}
119
Akron2d53b932026-07-15 12:12:54 +0200120// UnmarshalYAML rejects the deprecated comma-separated string format for
121// allowOrigins and requires a YAML list instead.
122func (m *MappingConfig) UnmarshalYAML(value *yaml.Node) error {
123 if value.Kind == yaml.MappingNode {
124 for i := 0; i < len(value.Content)-1; i += 2 {
125 if value.Content[i].Value == "allowOrigins" && value.Content[i+1].Kind == yaml.ScalarNode {
126 return fmt.Errorf(
127 "allowOrigins must be a YAML list, not a comma-separated string; update your config:\n" +
128 " allowOrigins:\n" +
129 " - \"https://example.com\"")
130 }
131 }
132 }
133 type plain MappingConfig
134 var p plain
135 if err := value.Decode(&p); err != nil {
136 return err
137 }
138 *m = MappingConfig(p)
139 return nil
140}
141
Akroned787d02026-05-20 12:31:07 +0200142// AllowedBasePath restricts file loading to a specific directory tree.
143// When set, all file paths must resolve to a location at or below this
144// directory (or under the system temp directory). Defaults to the CWD at
145// application startup; can be overridden via the "basePath" YAML config
146// field or the KORAL_MAPPER_BASE_PATH environment variable. In Docker
147// (WORKDIR /), the default "/" naturally allows all paths.
148var AllowedBasePath string
149
150// isWithinDir checks whether absPath is at or below the given directory.
151// Uses a trailing-separator comparison to avoid prefix false positives
152// (e.g. /home/user must not match /home/username).
153func isWithinDir(absPath, dir string) bool {
154 if dir == "/" {
155 return true
156 }
157 return absPath == dir || strings.HasPrefix(absPath, dir+string(filepath.Separator))
158}
159
160// sanitizeFilePath cleans a file path, resolves it to an absolute path, and
161// (when AllowedBasePath is set) verifies it resides at or below the allowed
162// base directory or the system temp directory. This prevents path
163// traversal attacks by ensuring os.ReadFile never receives
164// unsanitized user input and cannot access files outside the application's
165// working tree.
166func sanitizeFilePath(path string) (string, error) {
167 if path == "" {
168 return "", fmt.Errorf("empty file path")
169 }
170
171 // Clean the path to remove redundant separators and resolve "." and ".."
172 cleaned := filepath.Clean(path)
173
174 // Convert to absolute path so all traversal is resolved against the CWD
175 absPath, err := filepath.Abs(cleaned)
176 if err != nil {
177 return "", fmt.Errorf("failed to resolve absolute path for '%s': %w", path, err)
178 }
179
180 // If a base path is configured, confine access to that tree or temp dir
181 if AllowedBasePath != "" {
182 base := filepath.Clean(AllowedBasePath)
183 tmpDir := filepath.Clean(os.TempDir())
184
185 if !isWithinDir(absPath, base) && !isWithinDir(absPath, tmpDir) {
186 return "", fmt.Errorf(
187 "path traversal detected: '%s' resolves to '%s' which is outside the allowed base '%s'",
188 path, absPath, base)
189 }
190 }
191
192 return absPath, nil
193}
194
Akrone1cff7c2025-06-04 18:43:32 +0200195// LoadFromSources loads configuration from multiple sources and merges them:
196// - A main configuration file (optional) containing global settings and lists
197// - Individual mapping files (optional) containing single mapping lists each
198// At least one source must be provided
199func LoadFromSources(configFile string, mappingFiles []string) (*MappingConfig, error) {
200 var allLists []MappingList
201 var globalConfig MappingConfig
Akron57ee5582025-05-21 15:25:13 +0200202
Akrone1cff7c2025-06-04 18:43:32 +0200203 // Track seen IDs across all sources to detect duplicates
204 seenIDs := make(map[string]bool)
Akrona5d88142025-05-22 14:42:09 +0200205
Akrone1cff7c2025-06-04 18:43:32 +0200206 // Load main configuration file if provided
207 if configFile != "" {
Akroned787d02026-05-20 12:31:07 +0200208 safePath, err := sanitizeFilePath(configFile)
209 if err != nil {
210 return nil, err
211 }
212 data, err := os.ReadFile(safePath) // #nosec G304 -- path sanitized above
Akrone1cff7c2025-06-04 18:43:32 +0200213 if err != nil {
214 return nil, fmt.Errorf("failed to read config file '%s': %w", configFile, err)
Akron06d21f02025-06-04 14:36:07 +0200215 }
Akrone1cff7c2025-06-04 18:43:32 +0200216
217 if len(data) == 0 {
218 return nil, fmt.Errorf("EOF: config file '%s' is empty", configFile)
219 }
220
221 // Try to unmarshal as new format first (object with optional sdk/server and lists)
Akron813780f2025-06-05 15:44:28 +0200222 if err := yaml.Unmarshal(data, &globalConfig); err == nil {
223 // Successfully parsed as new format - accept it regardless of whether it has lists
Akrone1cff7c2025-06-04 18:43:32 +0200224 for _, list := range globalConfig.Lists {
225 if seenIDs[list.ID] {
226 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
227 }
228 seenIDs[list.ID] = true
229 }
230 allLists = append(allLists, globalConfig.Lists...)
Akron2d53b932026-07-15 12:12:54 +0200231 } else if strings.Contains(err.Error(), "allowOrigins must be") {
232 return nil, fmt.Errorf("failed to parse config file '%s': %w", configFile, err)
Akrone1cff7c2025-06-04 18:43:32 +0200233 } else {
234 // Fall back to old format (direct list)
235 var lists []MappingList
236 if err := yaml.Unmarshal(data, &lists); err != nil {
237 return nil, fmt.Errorf("failed to parse YAML config file '%s': %w", configFile, err)
238 }
239
240 for _, list := range lists {
241 if seenIDs[list.ID] {
242 return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
243 }
244 seenIDs[list.ID] = true
245 }
246 allLists = append(allLists, lists...)
247 // Clear the lists from globalConfig since we got them from the old format
248 globalConfig.Lists = nil
249 }
Akron06d21f02025-06-04 14:36:07 +0200250 }
251
Akrone1cff7c2025-06-04 18:43:32 +0200252 // Load individual mapping files
253 for _, file := range mappingFiles {
Akroned787d02026-05-20 12:31:07 +0200254 safePath, err := sanitizeFilePath(file)
255 if err != nil {
256 return nil, err
257 }
258 data, err := os.ReadFile(safePath) // #nosec G304 -- path sanitized above
Akrone1cff7c2025-06-04 18:43:32 +0200259 if err != nil {
Akron7e8da932025-07-01 11:56:46 +0200260 log.Error().Err(err).Str("file", file).Msg("Failed to read mapping file")
261 continue
Akrone1cff7c2025-06-04 18:43:32 +0200262 }
263
264 if len(data) == 0 {
Akron7e8da932025-07-01 11:56:46 +0200265 log.Error().Err(err).Str("file", file).Msg("EOF: mapping file is empty")
266 continue
Akrone1cff7c2025-06-04 18:43:32 +0200267 }
268
269 var list MappingList
270 if err := yaml.Unmarshal(data, &list); err != nil {
Akron7e8da932025-07-01 11:56:46 +0200271 log.Error().Err(err).Str("file", file).Msg("Failed to parse YAML mapping file")
272 continue
Akrone1cff7c2025-06-04 18:43:32 +0200273 }
274
275 if seenIDs[list.ID] {
Akron7e8da932025-07-01 11:56:46 +0200276 log.Error().Err(err).Str("file", file).Str("list-id", list.ID).Msg("Duplicate mapping list ID found")
277 continue
Akrone1cff7c2025-06-04 18:43:32 +0200278 }
279 seenIDs[list.ID] = true
280 allLists = append(allLists, list)
Akron57ee5582025-05-21 15:25:13 +0200281 }
282
Akrone1cff7c2025-06-04 18:43:32 +0200283 // Ensure we have at least some configuration
284 if len(allLists) == 0 {
285 return nil, fmt.Errorf("no mapping lists found: provide either a config file (-c) with lists or mapping files (-m)")
286 }
287
Akron585f50f2025-07-03 13:55:47 +0200288 // Validate all mapping lists (skip duplicate ID check since we already did it)
Akrone1cff7c2025-06-04 18:43:32 +0200289 if err := validateMappingLists(allLists); err != nil {
Akron06d21f02025-06-04 14:36:07 +0200290 return nil, err
291 }
292
Akrone1cff7c2025-06-04 18:43:32 +0200293 // Create final configuration
294 result := &MappingConfig{
Akronf1ca8822026-05-20 15:44:00 +0200295 SDK: globalConfig.SDK,
296 Stylesheet: globalConfig.Stylesheet,
297 Server: globalConfig.Server,
298 ServiceURL: globalConfig.ServiceURL,
299 BasePath: globalConfig.BasePath,
300 AllowOrigins: globalConfig.AllowOrigins,
301 Port: globalConfig.Port,
302 LogLevel: globalConfig.LogLevel,
303 RateLimit: globalConfig.RateLimit,
Akronf7bba072026-05-21 12:36:19 +0200304 Rewrites: globalConfig.Rewrites,
Akronf1ca8822026-05-20 15:44:00 +0200305 Lists: allLists,
Akrone1cff7c2025-06-04 18:43:32 +0200306 }
307
Akronf98ba282026-02-24 11:13:30 +0100308 // Apply environment variable overrides (ENV > config file)
309 ApplyEnvOverrides(result)
310
Akron06d21f02025-06-04 14:36:07 +0200311 // Apply defaults if not specified
Akron2ac2ec02025-06-05 15:26:42 +0200312 ApplyDefaults(result)
Akrone1cff7c2025-06-04 18:43:32 +0200313
314 return result, nil
315}
316
Akron585f50f2025-07-03 13:55:47 +0200317// ApplyDefaults sets default values for configuration fields if they are empty
Akron2ac2ec02025-06-05 15:26:42 +0200318func ApplyDefaults(config *MappingConfig) {
Akron585f50f2025-07-03 13:55:47 +0200319 defaults := map[*string]string{
320 &config.SDK: defaultSDK,
Akron43fb1022026-02-20 11:38:49 +0100321 &config.Stylesheet: defaultStylesheet,
Akron585f50f2025-07-03 13:55:47 +0200322 &config.Server: defaultServer,
323 &config.ServiceURL: defaultServiceURL,
Akron43fb1022026-02-20 11:38:49 +0100324 &config.CookieName: defaultCookieName,
Akron585f50f2025-07-03 13:55:47 +0200325 &config.LogLevel: defaultLogLevel,
Akron06d21f02025-06-04 14:36:07 +0200326 }
Akron585f50f2025-07-03 13:55:47 +0200327
328 for field, defaultValue := range defaults {
329 if *field == "" {
330 *field = defaultValue
331 }
Akron06d21f02025-06-04 14:36:07 +0200332 }
Akron585f50f2025-07-03 13:55:47 +0200333
Akrondaca3142026-05-21 13:00:45 +0200334 // AllowOrigins defaults to the Server value. This avoids duplicating
335 // the server URL string and keeps CORS in sync with the deployment.
Akron2d53b932026-07-15 12:12:54 +0200336 if len(config.AllowOrigins) == 0 {
337 config.AllowOrigins = []string{config.Server}
Akronf1ca8822026-05-20 15:44:00 +0200338 }
Akrondaca3142026-05-21 13:00:45 +0200339 config.AllowOrigins = normalizeOrigins(config.AllowOrigins)
Akronf1ca8822026-05-20 15:44:00 +0200340
Akrona8a66ce2025-06-05 10:50:17 +0200341 if config.Port == 0 {
342 config.Port = defaultPort
343 }
Akrone6767de2026-05-20 10:06:24 +0200344 if config.RateLimit == 0 {
345 config.RateLimit = defaultRateLimit
346 }
Akron06d21f02025-06-04 14:36:07 +0200347}
348
Akron2d53b932026-07-15 12:12:54 +0200349// normalizeOrigins strips path components from origin URLs, returning only
350// scheme + host (+ port when present). The CORS middleware requires bare
351// origins without paths; URLs like
Akrondaca3142026-05-21 13:00:45 +0200352// "https://example.com/instance/test" are pruned to "https://example.com".
Akron2d53b932026-07-15 12:12:54 +0200353func normalizeOrigins(origins []string) []string {
354 result := make([]string, 0, len(origins))
355 for _, origin := range origins {
356 origin = strings.TrimSpace(origin)
357 if origin == "" {
358 continue
359 }
360 if u, err := url.Parse(origin); err == nil && u.Host != "" {
361 result = append(result, u.Scheme+"://"+u.Host)
Akrondaca3142026-05-21 13:00:45 +0200362 } else {
Akron2d53b932026-07-15 12:12:54 +0200363 result = append(result, strings.TrimRight(origin, "/"))
Akrondaca3142026-05-21 13:00:45 +0200364 }
365 }
Akron2d53b932026-07-15 12:12:54 +0200366 return result
Akrondaca3142026-05-21 13:00:45 +0200367}
368
Akronf98ba282026-02-24 11:13:30 +0100369// ApplyEnvOverrides overrides configuration fields from environment variables.
370// All environment variables are uppercase and prefixed with KORAL_MAPPER_.
371// Non-empty environment values override any previously loaded config values.
372func ApplyEnvOverrides(config *MappingConfig) {
373 envMappings := map[string]*string{
Akron2d53b932026-07-15 12:12:54 +0200374 "KORAL_MAPPER_SERVER": &config.Server,
375 "KORAL_MAPPER_SDK": &config.SDK,
376 "KORAL_MAPPER_STYLESHEET": &config.Stylesheet,
377 "KORAL_MAPPER_SERVICE_URL": &config.ServiceURL,
378 "KORAL_MAPPER_COOKIE_NAME": &config.CookieName,
379 "KORAL_MAPPER_LOG_LEVEL": &config.LogLevel,
380 "KORAL_MAPPER_BASE_PATH": &config.BasePath,
Akronf98ba282026-02-24 11:13:30 +0100381 }
382
383 for envKey, field := range envMappings {
384 if val := os.Getenv(envKey); val != "" {
385 *field = val
386 }
387 }
388
Akron2d53b932026-07-15 12:12:54 +0200389 if val := os.Getenv("KORAL_MAPPER_ALLOW_ORIGINS"); val != "" {
390 config.AllowOrigins = strings.Split(val, ",")
391 }
392
Akronf98ba282026-02-24 11:13:30 +0100393 if val := os.Getenv("KORAL_MAPPER_PORT"); val != "" {
394 if port, err := strconv.Atoi(val); err == nil {
395 config.Port = port
396 }
397 }
Akrone6767de2026-05-20 10:06:24 +0200398
399 if val := os.Getenv("KORAL_MAPPER_RATE_LIMIT"); val != "" {
400 if rl, err := strconv.Atoi(val); err == nil {
401 config.RateLimit = rl
402 }
403 }
Akronf7bba072026-05-21 12:36:19 +0200404
405 if val := os.Getenv("KORAL_MAPPER_REWRITES"); val != "" {
406 config.Rewrites = val == "true"
407 }
Akronf98ba282026-02-24 11:13:30 +0100408}
409
Akron585f50f2025-07-03 13:55:47 +0200410// validateMappingLists validates a slice of mapping lists (without duplicate ID checking)
Akron06d21f02025-06-04 14:36:07 +0200411func validateMappingLists(lists []MappingList) error {
Akron57ee5582025-05-21 15:25:13 +0200412 for i, list := range lists {
413 if list.ID == "" {
Akron06d21f02025-06-04 14:36:07 +0200414 return fmt.Errorf("mapping list at index %d is missing an ID", i)
Akron57ee5582025-05-21 15:25:13 +0200415 }
Akrona5d88142025-05-22 14:42:09 +0200416
Akron57ee5582025-05-21 15:25:13 +0200417 if len(list.Mappings) == 0 {
Akron06d21f02025-06-04 14:36:07 +0200418 return fmt.Errorf("mapping list '%s' has no mapping rules", list.ID)
Akron57ee5582025-05-21 15:25:13 +0200419 }
420
421 // Validate each mapping rule
422 for j, rule := range list.Mappings {
423 if rule == "" {
Akron06d21f02025-06-04 14:36:07 +0200424 return fmt.Errorf("mapping list '%s' rule at index %d is empty", list.ID, j)
Akron57ee5582025-05-21 15:25:13 +0200425 }
426 }
427 }
Akron06d21f02025-06-04 14:36:07 +0200428 return nil
Akron57ee5582025-05-21 15:25:13 +0200429}
430
431// ParseMappings parses all mapping rules in a list and returns a slice of parsed rules
432func (list *MappingList) ParseMappings() ([]*parser.MappingResult, error) {
433 // Create a grammar parser with the list's default foundries and layers
434 grammarParser, err := parser.NewGrammarParser("", "")
435 if err != nil {
436 return nil, fmt.Errorf("failed to create grammar parser: %w", err)
437 }
438
439 results := make([]*parser.MappingResult, len(list.Mappings))
440 for i, rule := range list.Mappings {
Akrona5d88142025-05-22 14:42:09 +0200441 // Check for empty rules first
442 if rule == "" {
443 return nil, fmt.Errorf("empty mapping rule at index %d in list '%s'", i, list.ID)
444 }
445
Akron57ee5582025-05-21 15:25:13 +0200446 // Parse the mapping rule
447 result, err := grammarParser.ParseMapping(string(rule))
448 if err != nil {
449 return nil, fmt.Errorf("failed to parse mapping rule %d in list '%s': %w", i, list.ID, err)
450 }
451
452 // Apply default foundries and layers if not specified in the rule
453 if list.FoundryA != "" {
454 applyDefaultFoundryAndLayer(result.Upper.Wrap, list.FoundryA, list.LayerA)
455 }
456 if list.FoundryB != "" {
457 applyDefaultFoundryAndLayer(result.Lower.Wrap, list.FoundryB, list.LayerB)
458 }
459
460 results[i] = result
461 }
462
463 return results, nil
464}
465
466// applyDefaultFoundryAndLayer recursively applies default foundry and layer to terms that don't have them specified
467func applyDefaultFoundryAndLayer(node ast.Node, defaultFoundry, defaultLayer string) {
468 switch n := node.(type) {
469 case *ast.Term:
Akron585f50f2025-07-03 13:55:47 +0200470 if n.Foundry == "" && defaultFoundry != "" {
Akron57ee5582025-05-21 15:25:13 +0200471 n.Foundry = defaultFoundry
472 }
Akron585f50f2025-07-03 13:55:47 +0200473 if n.Layer == "" && defaultLayer != "" {
Akron57ee5582025-05-21 15:25:13 +0200474 n.Layer = defaultLayer
475 }
476 case *ast.TermGroup:
477 for _, op := range n.Operands {
478 applyDefaultFoundryAndLayer(op, defaultFoundry, defaultLayer)
479 }
480 }
481}