xvcl 2.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xvcl/compiler.py ADDED
@@ -0,0 +1,1541 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ xvcl - Extended VCL compiler with metaprogramming features
4
+
5
+ Features:
6
+ - #inline directive for zero-overhead text substitution macros
7
+ - Automatic parenthesization to prevent operator precedence issues
8
+ - Macros work in any expression context (unlike functions)
9
+ - #include directive for code reuse across files
10
+ - #const directive for named constants
11
+ - Better error messages with line numbers and context
12
+ - --debug mode for tracing expansion
13
+ - Source maps (optional)
14
+ - For loops, conditionals, template expressions
15
+ - Functions with single/tuple returns
16
+ - #let directive for variable declaration
17
+ """
18
+
19
+ import argparse
20
+ import os
21
+ import re
22
+ import sys
23
+ from dataclasses import dataclass
24
+ from difflib import get_close_matches
25
+ from typing import Any, Optional
26
+
27
+
28
+ # ANSI color codes for terminal output
29
+ class Colors:
30
+ RED = "\033[91m"
31
+ YELLOW = "\033[93m"
32
+ GREEN = "\033[92m"
33
+ BLUE = "\033[94m"
34
+ CYAN = "\033[96m"
35
+ BOLD = "\033[1m"
36
+ RESET = "\033[0m"
37
+ GRAY = "\033[90m"
38
+
39
+
40
+ @dataclass
41
+ class SourceLocation:
42
+ """Tracks source location for error reporting."""
43
+
44
+ file: str
45
+ line: int
46
+
47
+ def __str__(self):
48
+ return f"{self.file}:{self.line}"
49
+
50
+
51
+ class PreprocessorError(Exception):
52
+ """Base exception for preprocessor errors."""
53
+
54
+ def __init__(
55
+ self,
56
+ message: str,
57
+ location: Optional[SourceLocation] = None,
58
+ context_lines: Optional[list[tuple[int, str]]] = None,
59
+ ):
60
+ self.message = message
61
+ self.location = location
62
+ self.context_lines = context_lines
63
+ super().__init__(message)
64
+
65
+ def format_error(self, use_colors: bool = True) -> str:
66
+ """Format error with context for display."""
67
+ parts = []
68
+
69
+ # Error header
70
+ if use_colors:
71
+ parts.append(f"{Colors.RED}{Colors.BOLD}Error{Colors.RESET}")
72
+ else:
73
+ parts.append("Error")
74
+
75
+ if self.location:
76
+ parts.append(f" at {self.location}:")
77
+ else:
78
+ parts.append(":")
79
+
80
+ parts.append(f"\n {self.message}\n")
81
+
82
+ # Context lines
83
+ if self.context_lines and self.location:
84
+ parts.append("\n Context:\n")
85
+ for line_num, line_text in self.context_lines:
86
+ prefix = " → " if line_num == self.location.line else " "
87
+ if use_colors and line_num == self.location.line:
88
+ parts.append(f"{Colors.BOLD}{prefix}{line_num}: {line_text}{Colors.RESET}\n")
89
+ else:
90
+ parts.append(f"{prefix}{line_num}: {line_text}\n")
91
+
92
+ return "".join(parts)
93
+
94
+
95
+ class Macro:
96
+ """Represents an inline macro definition."""
97
+
98
+ def __init__(
99
+ self, name: str, params: list[str], body: str, location: Optional[SourceLocation] = None
100
+ ):
101
+ self.name = name
102
+ self.params = params # [param_name, ...]
103
+ self.body = body # Single expression (string)
104
+ self.location = location
105
+
106
+ def expand(self, args: list[str]) -> str:
107
+ """Expand macro by substituting parameters with arguments."""
108
+ if len(args) != len(self.params):
109
+ raise ValueError(
110
+ f"Macro {self.name} expects {len(self.params)} arguments, got {len(args)}"
111
+ )
112
+
113
+ # Start with body
114
+ result = self.body
115
+
116
+ # Replace each parameter with its argument
117
+ # Use word boundaries to avoid partial replacements
118
+ for param, arg in zip(self.params, args):
119
+ # Only wrap argument in parentheses if it contains operators
120
+ # This avoids creating grouped expressions for simple values
121
+ if any(op in arg for op in ["+", "-", "*", "/", "%", "==", "!=", "<", ">", "&&", "||"]):
122
+ wrapped_arg = f"({arg})"
123
+ else:
124
+ wrapped_arg = arg
125
+ result = re.sub(rf"\b{re.escape(param)}\b", wrapped_arg, result)
126
+
127
+ # Don't wrap entire expression - let VCL handle operator precedence naturally
128
+ return result
129
+
130
+
131
+ class Function:
132
+ """Represents a VCL function definition."""
133
+
134
+ def __init__(
135
+ self,
136
+ name: str,
137
+ params: list[tuple[str, str]],
138
+ return_type,
139
+ body: list[str],
140
+ location: Optional[SourceLocation] = None,
141
+ ):
142
+ self.name = name
143
+ self.params = params # [(param_name, param_type), ...]
144
+ self.return_type = return_type # str for single return, List[str] for tuple
145
+ self.body = body
146
+ self.location = location
147
+
148
+ def get_param_global(self, param_name: str) -> str:
149
+ """Get the global header name for a parameter."""
150
+ return f"req.http.X-Func-{self.name}-{param_name}"
151
+
152
+ def get_return_global(self, index: Optional[int] = None) -> str:
153
+ """Get the global header name for the return value."""
154
+ if isinstance(self.return_type, list):
155
+ # Tuple return - use indexed global
156
+ if index is None:
157
+ raise ValueError(f"Function {self.name} returns tuple, index required")
158
+ return f"req.http.X-Func-{self.name}-Return{index}"
159
+ else:
160
+ # Single return
161
+ return f"req.http.X-Func-{self.name}-Return"
162
+
163
+ def is_tuple_return(self) -> bool:
164
+ """Check if function returns a tuple."""
165
+ return isinstance(self.return_type, list)
166
+
167
+ def get_return_types(self) -> list[str]:
168
+ """Get return types as a list (single type becomes 1-element list)."""
169
+ if isinstance(self.return_type, list):
170
+ return self.return_type
171
+ else:
172
+ return [self.return_type]
173
+
174
+
175
+ class XVCLCompiler:
176
+ """Extended VCL compiler with loops, conditionals, templates, functions, includes, and constants."""
177
+
178
+ def __init__(
179
+ self,
180
+ include_paths: Optional[list[str]] = None,
181
+ debug: bool = False,
182
+ source_maps: bool = False,
183
+ ):
184
+ self.include_paths = include_paths or ["."]
185
+ self.debug = debug
186
+ self.source_maps = source_maps
187
+
188
+ # State
189
+ self.variables: dict[str, Any] = {}
190
+ self.constants: dict[str, Any] = {} # Constants defined with #const
191
+ self.macros: dict[str, Macro] = {} # NEW in v2.4: Inline macros
192
+ self.functions: dict[str, Function] = {}
193
+ self.output: list[str] = []
194
+
195
+ # Include tracking
196
+ self.included_files: set[str] = set() # Absolute paths of included files
197
+ self.include_stack: list[str] = [] # Stack for cycle detection
198
+
199
+ # Current source location for error reporting
200
+ self.current_file: str = ""
201
+ self.current_line: int = 0
202
+ self.current_lines: list[str] = [] # All lines for context
203
+
204
+ def log_debug(self, message: str, indent: int = 0):
205
+ """Log debug message if debug mode is enabled."""
206
+ if self.debug:
207
+ prefix = " " * indent
208
+ print(f"{Colors.GRAY}[DEBUG]{Colors.RESET} {prefix}{message}")
209
+
210
+ def get_context_lines(self, line_num: int, context: int = 3) -> list[tuple[int, str]]:
211
+ """Get context lines around the given line number."""
212
+ if not self.current_lines:
213
+ return []
214
+
215
+ start = max(0, line_num - context - 1)
216
+ end = min(len(self.current_lines), line_num + context)
217
+
218
+ result = []
219
+ for i in range(start, end):
220
+ result.append((i + 1, self.current_lines[i].rstrip()))
221
+
222
+ return result
223
+
224
+ def make_error(self, message: str, line_num: Optional[int] = None) -> PreprocessorError:
225
+ """Create a PreprocessorError with context."""
226
+ loc = SourceLocation(self.current_file, line_num or self.current_line)
227
+ context = self.get_context_lines(line_num or self.current_line)
228
+ return PreprocessorError(message, loc, context)
229
+
230
+ def process_file(self, input_path: str, output_path: str) -> None:
231
+ """Process a VCL template file and write the result."""
232
+ self.log_debug(f"Processing file: {input_path}")
233
+
234
+ try:
235
+ with open(input_path) as f:
236
+ template = f.read()
237
+ except FileNotFoundError:
238
+ raise FileNotFoundError(f"XVCL file not found: {input_path}")
239
+ except Exception as e:
240
+ raise Exception(f"Error reading file {input_path}: {e}")
241
+
242
+ self.current_file = input_path
243
+ result = self.process(template, input_path)
244
+
245
+ try:
246
+ with open(output_path, "w") as f:
247
+ f.write(result)
248
+ except Exception as e:
249
+ raise Exception(f"Error writing output file {output_path}: {e}")
250
+
251
+ # Summary
252
+ print(f"{Colors.GREEN}✓{Colors.RESET} Compiled {input_path} -> {output_path}")
253
+ if self.constants:
254
+ print(f" Constants: {len(self.constants)}")
255
+ if self.macros:
256
+ print(f" Macros: {len(self.macros)} ({', '.join(self.macros.keys())})")
257
+ if self.functions:
258
+ print(f" Functions: {len(self.functions)} ({', '.join(self.functions.keys())})")
259
+ if len(self.included_files) > 1: # More than just the main file
260
+ print(f" Included files: {len(self.included_files) - 1}")
261
+
262
+ def process(self, template: str, filename: str = "<string>") -> str:
263
+ """Process a template string and return the result."""
264
+ self.log_debug(f"Starting processing of {filename}")
265
+
266
+ self.output = []
267
+ self.current_file = filename
268
+ self.current_lines = template.split("\n")
269
+
270
+ # Add to included files (using absolute path)
271
+ abs_path = os.path.abspath(filename) if os.path.exists(filename) else filename
272
+ self.included_files.add(abs_path)
273
+
274
+ lines = self.current_lines
275
+
276
+ # First pass: extract constants
277
+ self.log_debug("Pass 1: Extracting constants")
278
+ lines = self._extract_constants(lines)
279
+
280
+ # Second pass: process includes
281
+ self.log_debug("Pass 2: Processing includes")
282
+ lines = self._process_includes(lines, filename)
283
+
284
+ # Third pass: extract inline macros (NEW in v2.4)
285
+ self.log_debug("Pass 3: Extracting inline macros")
286
+ lines = self._extract_macros(lines)
287
+
288
+ # Fourth pass: extract function definitions
289
+ self.log_debug("Pass 4: Extracting functions")
290
+ lines = self._extract_functions(lines)
291
+
292
+ # NEW: Fourth-and-a-half pass: join multi-line function calls
293
+ self.log_debug("Pass 4.5: Joining multi-line function calls")
294
+ lines = self._join_multiline_function_calls(lines)
295
+
296
+ # Fifth pass: process loops/conditionals and replace function calls/macros
297
+ self.log_debug("Pass 5: Processing directives and generating code")
298
+ self._process_lines(lines, 0, len(lines), {})
299
+
300
+ # Sixth pass: append function subroutine implementations
301
+ self.log_debug("Pass 6: Generating function subroutines")
302
+ self._generate_function_subroutines()
303
+
304
+ self.log_debug(f"Processing complete: {len(self.output)} lines generated")
305
+ return "\n".join(self.output)
306
+
307
+ def _extract_constants(self, lines: list[str]) -> list[str]:
308
+ """
309
+ Extract #const declarations and store them.
310
+ Returns lines with const declarations removed.
311
+ """
312
+ result = []
313
+ i = 0
314
+ while i < len(lines):
315
+ line = lines[i]
316
+ stripped = line.strip()
317
+ self.current_line = i + 1
318
+
319
+ if stripped.startswith("#const "):
320
+ # Parse: #const NAME TYPE = value
321
+ match = re.match(r"#const\s+(\w+)\s+(\w+)\s*=\s*(.+)", stripped)
322
+ if not match:
323
+ # Try without type: #const NAME = value (infer type)
324
+ match = re.match(r"#const\s+(\w+)\s*=\s*(.+)", stripped)
325
+ if not match:
326
+ raise self.make_error(f"Invalid #const syntax: {stripped}")
327
+
328
+ name = match.group(1)
329
+ const_type = None # Infer from value
330
+ value_expr = match.group(2)
331
+ else:
332
+ name = match.group(1)
333
+ const_type = match.group(2)
334
+ value_expr = match.group(3)
335
+
336
+ # Evaluate the expression
337
+ try:
338
+ value = self._evaluate_expression(value_expr, {})
339
+ except Exception as e:
340
+ raise self.make_error(f"Error evaluating constant '{name}': {e}")
341
+
342
+ # Type checking if type was specified
343
+ if const_type:
344
+ expected_type = self._python_type_from_vcl(const_type)
345
+ if not isinstance(value, expected_type):
346
+ raise self.make_error(
347
+ f"Constant '{name}' type mismatch: expected {const_type}, "
348
+ f"got {type(value).__name__}"
349
+ )
350
+
351
+ self.constants[name] = value
352
+ self.log_debug(f"Defined constant: {name} = {value}")
353
+ i += 1
354
+ else:
355
+ result.append(line)
356
+ i += 1
357
+
358
+ return result
359
+
360
+ def _python_type_from_vcl(self, vcl_type: str) -> type:
361
+ """Convert VCL type name to Python type for validation."""
362
+ type_map = {
363
+ "INTEGER": int,
364
+ "STRING": str,
365
+ "FLOAT": float,
366
+ "BOOL": bool,
367
+ }
368
+ return type_map.get(vcl_type, object)
369
+
370
+ def _process_includes(self, lines: list[str], current_file: str) -> list[str]:
371
+ """
372
+ Process #include directives and insert included file contents.
373
+ Returns lines with includes expanded.
374
+ """
375
+ result = []
376
+ i = 0
377
+
378
+ while i < len(lines):
379
+ line = lines[i]
380
+ stripped = line.strip()
381
+ self.current_line = i + 1
382
+
383
+ if stripped.startswith("#include "):
384
+ # Parse: #include "path/to/file.xvcl"
385
+ match = re.match(r'#include\s+["\'](.+?)["\']', stripped)
386
+ if not match:
387
+ # Try without quotes: #include <stdlib/file.xvcl>
388
+ match = re.match(r"#include\s+<(.+?)>", stripped)
389
+
390
+ if not match:
391
+ raise self.make_error(f"Invalid #include syntax: {stripped}")
392
+
393
+ include_path = match.group(1)
394
+
395
+ # Resolve include path
396
+ resolved_path = self._resolve_include_path(include_path, current_file)
397
+
398
+ if not resolved_path:
399
+ raise self.make_error(f"Cannot find included file: {include_path}")
400
+
401
+ abs_path = os.path.abspath(resolved_path)
402
+
403
+ # Check for cycles
404
+ if abs_path in self.include_stack:
405
+ cycle = " -> ".join(self.include_stack + [abs_path])
406
+ raise self.make_error(f"Circular include detected: {cycle}")
407
+
408
+ # Check if already included (include-once semantics)
409
+ if abs_path in self.included_files:
410
+ self.log_debug(f"Skipping already included file: {resolved_path}")
411
+ i += 1
412
+ continue
413
+
414
+ self.log_debug(f"Including file: {resolved_path}")
415
+
416
+ # Read and process included file
417
+ try:
418
+ with open(resolved_path) as f:
419
+ included_content = f.read()
420
+ except Exception as e:
421
+ raise self.make_error(f"Error reading included file {resolved_path}: {e}")
422
+
423
+ # Add to included files and stack
424
+ self.included_files.add(abs_path)
425
+ self.include_stack.append(abs_path)
426
+
427
+ # Save current state
428
+ saved_file = self.current_file
429
+ saved_line = self.current_line
430
+ saved_lines = self.current_lines
431
+
432
+ # Process included file
433
+ self.current_file = resolved_path
434
+ self.current_lines = included_content.split("\n")
435
+
436
+ # Recursively process includes in the included file
437
+ included_lines = self._extract_constants(self.current_lines)
438
+ included_lines = self._process_includes(included_lines, resolved_path)
439
+
440
+ # Restore state
441
+ self.current_file = saved_file
442
+ self.current_line = saved_line
443
+ self.current_lines = saved_lines
444
+
445
+ # Pop from stack
446
+ self.include_stack.pop()
447
+
448
+ # Add comment marker if source maps enabled
449
+ if self.source_maps:
450
+ result.append(f"// BEGIN INCLUDE: {include_path}")
451
+
452
+ # Add included lines to result
453
+ result.extend(included_lines)
454
+
455
+ if self.source_maps:
456
+ result.append(f"// END INCLUDE: {include_path}")
457
+
458
+ i += 1
459
+ else:
460
+ result.append(line)
461
+ i += 1
462
+
463
+ return result
464
+
465
+ def _resolve_include_path(self, include_path: str, current_file: str) -> Optional[str]:
466
+ """Resolve include path by searching include paths."""
467
+ # Try relative to current file first
468
+ if current_file and current_file != "<string>":
469
+ current_dir = os.path.dirname(os.path.abspath(current_file))
470
+ candidate = os.path.join(current_dir, include_path)
471
+ if os.path.exists(candidate):
472
+ return candidate
473
+
474
+ # Try include paths
475
+ for search_path in self.include_paths:
476
+ candidate = os.path.join(search_path, include_path)
477
+ if os.path.exists(candidate):
478
+ return candidate
479
+
480
+ return None
481
+
482
+ def _extract_macros(self, lines: list[str]) -> list[str]:
483
+ """
484
+ Extract #inline...#endinline blocks and store them as macros.
485
+ Returns lines with macro definitions removed.
486
+ """
487
+ result = []
488
+ i = 0
489
+ while i < len(lines):
490
+ line = lines[i]
491
+ stripped = line.strip()
492
+ self.current_line = i + 1
493
+
494
+ if stripped.startswith("#inline "):
495
+ # Parse: #inline name(param1, param2, ...)
496
+ match = re.match(r"#inline\s+(\w+)\s*\(([^)]*)\)", stripped)
497
+ if not match:
498
+ raise self.make_error(f"Invalid #inline syntax: {stripped}")
499
+
500
+ name = match.group(1)
501
+ params_str = match.group(2).strip()
502
+
503
+ # Check for duplicate macro names
504
+ if name in self.macros:
505
+ raise self.make_error(
506
+ f"Macro '{name}' already defined at {self.macros[name].location}"
507
+ )
508
+
509
+ # Parse parameters (comma-separated)
510
+ params = []
511
+ if params_str:
512
+ params = [p.strip() for p in params_str.split(",")]
513
+
514
+ # Find matching #endinline
515
+ try:
516
+ endinline_idx = self._find_matching_end(
517
+ lines, i, len(lines), "#inline", "#endinline"
518
+ )
519
+ except SyntaxError as e:
520
+ raise self.make_error(str(e))
521
+
522
+ # Extract macro body (should be a single expression)
523
+ body_lines = lines[i + 1 : endinline_idx]
524
+ # Join all lines and strip whitespace
525
+ body = " ".join(line.strip() for line in body_lines).strip()
526
+
527
+ if not body:
528
+ raise self.make_error(f"Macro '{name}' has empty body")
529
+
530
+ # Store macro
531
+ location = SourceLocation(self.current_file, i + 1)
532
+ self.macros[name] = Macro(name, params, body, location)
533
+
534
+ self.log_debug(f"Defined macro: {name}({', '.join(params)})")
535
+
536
+ # Skip past #endinline
537
+ i = endinline_idx + 1
538
+ else:
539
+ result.append(line)
540
+ i += 1
541
+
542
+ return result
543
+
544
+ def _extract_functions(self, lines: list[str]) -> list[str]:
545
+ """
546
+ Extract #def...#enddef blocks and store them as functions.
547
+ Returns lines with function definitions removed.
548
+ """
549
+ result = []
550
+ i = 0
551
+ while i < len(lines):
552
+ line = lines[i]
553
+ stripped = line.strip()
554
+ self.current_line = i + 1
555
+
556
+ if stripped.startswith("#def "):
557
+ # Parse function definition
558
+ func_def = self._parse_function_def(stripped)
559
+ if not func_def:
560
+ raise self.make_error(f"Invalid #def syntax: {stripped}")
561
+
562
+ name, params, return_type = func_def
563
+
564
+ # Check for duplicate function names
565
+ if name in self.functions:
566
+ raise self.make_error(
567
+ f"Function '{name}' already defined at {self.functions[name].location}"
568
+ )
569
+
570
+ # Find matching #enddef
571
+ try:
572
+ enddef_idx = self._find_matching_end(lines, i, len(lines), "#def", "#enddef")
573
+ except SyntaxError as e:
574
+ raise self.make_error(str(e))
575
+
576
+ # Extract function body
577
+ body = lines[i + 1 : enddef_idx]
578
+
579
+ # Store function
580
+ location = SourceLocation(self.current_file, i + 1)
581
+ self.functions[name] = Function(name, params, return_type, body, location)
582
+
583
+ self.log_debug(
584
+ f"Defined function: {name}({', '.join(p[0] for p in params)}) -> {return_type}"
585
+ )
586
+
587
+ # Skip past #enddef
588
+ i = enddef_idx + 1
589
+ else:
590
+ result.append(line)
591
+ i += 1
592
+
593
+ return result
594
+
595
+ def _parse_function_def(self, line: str):
596
+ """
597
+ Parse function definition line.
598
+ Format: #def name(param1 TYPE, param2 TYPE) -> RETURN_TYPE
599
+ #def name(param1 TYPE, param2 TYPE) -> (TYPE1, TYPE2, ...)
600
+ Returns: (name, [(param, type), ...], return_type)
601
+ return_type is str for single, List[str] for tuple
602
+ """
603
+ # Try tuple return first: #def name(params) -> (TYPE1, TYPE2, ...)
604
+ tuple_match = re.match(r"#def\s+(\w+)\s*\((.*?)\)\s*->\s*\((.*?)\)", line)
605
+ if tuple_match:
606
+ name = tuple_match.group(1)
607
+ params_str = tuple_match.group(2).strip()
608
+ return_types_str = tuple_match.group(3).strip()
609
+
610
+ # Parse return types
611
+ return_types = [rt.strip() for rt in return_types_str.split(",") if rt.strip()]
612
+
613
+ # Parse parameters
614
+ params = []
615
+ if params_str:
616
+ for param in params_str.split(","):
617
+ param = param.strip()
618
+ if " " in param:
619
+ param_name, param_type = param.rsplit(" ", 1)
620
+ params.append((param_name.strip(), param_type.strip()))
621
+ else:
622
+ params.append((param, "STRING"))
623
+
624
+ return (name, params, return_types) # List for tuple return
625
+
626
+ # Try single return: #def name(params) -> TYPE
627
+ single_match = re.match(r"#def\s+(\w+)\s*\((.*?)\)\s*->\s*(\w+)", line)
628
+ if not single_match:
629
+ return None
630
+
631
+ name = single_match.group(1)
632
+ params_str = single_match.group(2).strip()
633
+ return_type = single_match.group(3)
634
+
635
+ # Parse parameters: "param1 TYPE, param2 TYPE"
636
+ params = []
637
+ if params_str:
638
+ for param in params_str.split(","):
639
+ param = param.strip()
640
+ if " " in param:
641
+ param_name, param_type = param.rsplit(" ", 1)
642
+ params.append((param_name.strip(), param_type.strip()))
643
+ else:
644
+ # Just param name, assume STRING
645
+ params.append((param, "STRING"))
646
+
647
+ return (name, params, return_type) # str for single return
648
+
649
+ def _generate_function_subroutines(self) -> None:
650
+ """Generate VCL subroutines for all defined functions."""
651
+ if not self.functions:
652
+ return
653
+
654
+ if self.output and self.output[-1].strip():
655
+ self.output.append("")
656
+ self.output.append(
657
+ "// ============================================================================"
658
+ )
659
+ self.output.append("// GENERATED FUNCTION SUBROUTINES")
660
+ self.output.append(
661
+ "// ============================================================================"
662
+ )
663
+ self.output.append("")
664
+
665
+ for func in self.functions.values():
666
+ self._generate_function_subroutine(func)
667
+
668
+ def _generate_function_subroutine(self, func: Function) -> None:
669
+ """Generate a VCL subroutine for a function."""
670
+ if self.source_maps and func.location:
671
+ self.output.append(f"// Generated from {func.location}")
672
+
673
+ self.output.append(f"// Function: {func.name}")
674
+ self.output.append("//@recv, hash, hit, miss, pass, fetch, error, deliver, log")
675
+ self.output.append(f"sub {func.name} {{")
676
+ self.output.append("")
677
+
678
+ # Declare local variables for parameters
679
+ for param_name, param_type in func.params:
680
+ self.output.append(f" declare local var.{param_name} {param_type};")
681
+
682
+ if func.params:
683
+ self.output.append("")
684
+
685
+ # Read parameters from globals with type conversion
686
+ for param_name, param_type in func.params:
687
+ global_name = func.get_param_global(param_name)
688
+ if param_type == "INTEGER":
689
+ self.output.append(f" set var.{param_name} = std.atoi({global_name});")
690
+ elif param_type == "FLOAT":
691
+ self.output.append(f" set var.{param_name} = std.atof({global_name});")
692
+ elif param_type == "BOOL":
693
+ self.output.append(f' set var.{param_name} = ({global_name} == "true");')
694
+ else:
695
+ # STRING and others
696
+ self.output.append(f" set var.{param_name} = {global_name};")
697
+
698
+ if func.params:
699
+ self.output.append("")
700
+
701
+ # Declare return variable(s)
702
+ return_types = func.get_return_types()
703
+ if func.is_tuple_return():
704
+ # Multiple return values
705
+ for idx, ret_type in enumerate(return_types):
706
+ self.output.append(f" declare local var.return_value{idx} {ret_type};")
707
+ else:
708
+ # Single return value
709
+ self.output.append(f" declare local var.return_value {return_types[0]};")
710
+ self.output.append("")
711
+
712
+ # Process function body
713
+ param_substituted_body = []
714
+ for line in func.body:
715
+ processed_line = line
716
+ for param_name, _ in func.params:
717
+ # Replace standalone parameter references
718
+ processed_line = re.sub(rf"\b{param_name}\b", f"var.{param_name}", processed_line)
719
+ param_substituted_body.append(processed_line)
720
+
721
+ # Save current output and process body
722
+ saved_output = self.output
723
+ self.output = []
724
+
725
+ self._process_lines(param_substituted_body, 0, len(param_substituted_body), {})
726
+
727
+ body_output = self.output
728
+ self.output = saved_output
729
+
730
+ # Post-process the body output to handle return statements
731
+ for line in body_output:
732
+ # Replace "return expr1, expr2;" with multiple assignments
733
+ if re.match(r"\s*return\s+", line):
734
+ if func.is_tuple_return():
735
+ # Parse: return expr1, expr2, expr3;
736
+ return_match = re.search(r"\breturn\s+(.+);", line)
737
+ if return_match:
738
+ exprs_str = return_match.group(1)
739
+ exprs = [e.strip() for e in exprs_str.split(",")]
740
+
741
+ if len(exprs) != len(return_types):
742
+ raise ValueError(
743
+ f"Function {func.name} expects {len(return_types)} return values, got {len(exprs)}"
744
+ )
745
+
746
+ match_indent = re.match(r"(\s*)", line)
747
+ indent = match_indent.group(1) if match_indent else ""
748
+ for idx, expr in enumerate(exprs):
749
+ self.output.append(f"{indent}set var.return_value{idx} = {expr};")
750
+ continue
751
+ else:
752
+ # Single return
753
+ line = re.sub(r"\breturn\s+(.+);", r"set var.return_value = \1;", line)
754
+
755
+ self.output.append(line)
756
+
757
+ # Write return value(s) to global(s)
758
+ self.output.append("")
759
+ if func.is_tuple_return():
760
+ for idx, ret_type in enumerate(return_types):
761
+ return_global = func.get_return_global(idx)
762
+ self._write_return_conversion(return_global, f"var.return_value{idx}", ret_type)
763
+ else:
764
+ return_global = func.get_return_global()
765
+ self._write_return_conversion(return_global, "var.return_value", return_types[0])
766
+
767
+ self.output.append("}")
768
+ self.output.append("")
769
+
770
+ def _write_return_conversion(self, global_var: str, local_var: str, var_type: str) -> None:
771
+ """Helper to write type conversion for return value."""
772
+ if var_type == "INTEGER":
773
+ self.output.append(f" set {global_var} = std.itoa({local_var});")
774
+ elif var_type == "FLOAT":
775
+ self.output.append(f' set {global_var} = "" + {local_var};')
776
+ elif var_type == "BOOL":
777
+ self.output.append(f" if ({local_var}) {{")
778
+ self.output.append(f' set {global_var} = "true";')
779
+ self.output.append(" } else {")
780
+ self.output.append(f' set {global_var} = "false";')
781
+ self.output.append(" }")
782
+ else:
783
+ # STRING and others
784
+ self.output.append(f" set {global_var} = {local_var};")
785
+
786
+ def _process_lines(
787
+ self, lines: list[str], start: int, end: int, context: dict[str, Any]
788
+ ) -> int:
789
+ """
790
+ Process lines from start to end with given context.
791
+ Returns the index of the last processed line.
792
+ """
793
+ i = start
794
+ while i < end:
795
+ line = lines[i]
796
+ stripped = line.strip()
797
+ self.current_line = i + 1
798
+
799
+ # Handle #for loops
800
+ if stripped.startswith("#for "):
801
+ self.log_debug(f"Processing #for at line {self.current_line}", indent=1)
802
+ i = self._process_for_loop(lines, i, end, context)
803
+
804
+ # Handle #if conditionals
805
+ elif stripped.startswith("#if "):
806
+ self.log_debug(f"Processing #if at line {self.current_line}", indent=1)
807
+ i = self._process_if(lines, i, end, context)
808
+
809
+ # Handle #let (declare + initialize)
810
+ elif stripped.startswith("#let "):
811
+ self.log_debug(f"Processing #let at line {self.current_line}", indent=1)
812
+ self._process_let(line)
813
+ i += 1
814
+
815
+ # Skip control flow keywords
816
+ elif stripped in ("#else", "#endif", "#endfor", "#enddef", "#endinline"):
817
+ return i
818
+
819
+ # Regular line - process function calls and template substitutions
820
+ else:
821
+ processed_line = self._process_function_calls(line)
822
+ processed_line = self._substitute_expressions(processed_line, context)
823
+ self.output.append(processed_line)
824
+ i += 1
825
+
826
+ return i
827
+
828
+ def _process_let(self, line: str) -> None:
829
+ """
830
+ Process #let directive (declare + initialize).
831
+ Format: #let name TYPE = expression;
832
+ Generates: declare local var.name TYPE;
833
+ set var.name = expression;
834
+ """
835
+ # Match: #let name TYPE = expression;
836
+ match = re.match(r"(\s*)#let\s+(\w+)\s+(\w+)\s*=\s*(.+);", line)
837
+ if not match:
838
+ raise self.make_error(f"Invalid #let syntax: {line}")
839
+
840
+ indent = match.group(1)
841
+ var_name = match.group(2)
842
+ var_type = match.group(3)
843
+ expression = match.group(4)
844
+
845
+ self.log_debug(f"Declaring variable: var.{var_name} {var_type} = {expression}", indent=2)
846
+
847
+ # Generate declare statement
848
+ self.output.append(f"{indent}declare local var.{var_name} {var_type};")
849
+
850
+ # Generate set statement and process any function calls in the expression
851
+ set_statement = f"{indent}set var.{var_name} = {expression};"
852
+ processed_set = self._process_function_calls(set_statement)
853
+
854
+ # The processed_set might be multi-line if it contains function calls
855
+ if "\n" in processed_set:
856
+ self.output.extend(processed_set.split("\n"))
857
+ else:
858
+ self.output.append(processed_set)
859
+
860
+ def _count_unquoted_parens(self, text: str) -> int:
861
+ """
862
+ Count parentheses balance, ignoring those inside string literals.
863
+ Returns positive number for more opens than closes.
864
+ """
865
+ depth = 0
866
+ in_string = False
867
+ string_char = None
868
+ i = 0
869
+ while i < len(text):
870
+ char = text[i]
871
+ if in_string:
872
+ if char == "\\" and i + 1 < len(text):
873
+ # Skip escaped character
874
+ i += 2
875
+ continue
876
+ elif char == string_char:
877
+ in_string = False
878
+ else:
879
+ if char in ('"', "'"):
880
+ in_string = True
881
+ string_char = char
882
+ elif char == "(":
883
+ depth += 1
884
+ elif char == ")":
885
+ depth -= 1
886
+ i += 1
887
+ return depth
888
+
889
+ def _join_multiline_function_calls(self, lines: list[str]) -> list[str]:
890
+ """
891
+ Join multi-line function calls into single lines.
892
+ Transforms:
893
+ set var.x = func(
894
+ arg1,
895
+ arg2
896
+ );
897
+ Into:
898
+ set var.x = func(arg1, arg2);
899
+ """
900
+ result = []
901
+ i = 0
902
+
903
+ while i < len(lines):
904
+ line = lines[i]
905
+
906
+ # Check if this line contains an opening parenthesis
907
+ if "(" not in line:
908
+ result.append(line)
909
+ i += 1
910
+ continue
911
+
912
+ # Count parentheses (ignoring those in strings) to see if they balance
913
+ paren_depth = self._count_unquoted_parens(line)
914
+
915
+ if paren_depth == 0:
916
+ # Balanced on this line, no joining needed
917
+ result.append(line)
918
+ i += 1
919
+ continue
920
+
921
+ # Unbalanced - need to join with following lines
922
+ accumulated = [line]
923
+ i += 1
924
+
925
+ while i < len(lines) and paren_depth > 0:
926
+ next_line = lines[i]
927
+ accumulated.append(next_line)
928
+ paren_depth += self._count_unquoted_parens(next_line)
929
+ i += 1
930
+
931
+ # Join the accumulated lines
932
+ # Preserve the indentation of the first line
933
+ leading_ws = len(line) - len(line.lstrip())
934
+ indent = line[:leading_ws]
935
+
936
+ # Join all lines, removing leading/trailing whitespace from each
937
+ joined_parts = []
938
+ for part in accumulated:
939
+ stripped = part.strip()
940
+ if stripped:
941
+ joined_parts.append(stripped)
942
+
943
+ joined = " ".join(joined_parts)
944
+
945
+ # Add back the original indentation
946
+ result.append(indent + joined)
947
+
948
+ return result
949
+
950
+ def _find_matching_paren(self, text: str, start: int) -> Optional[int]:
951
+ """Find the matching ')' for '(' at index start, ignoring strings."""
952
+ if start < 0 or start >= len(text) or text[start] != "(":
953
+ return None
954
+
955
+ depth = 1
956
+ pos = start + 1
957
+ in_string = False
958
+ string_char = None
959
+
960
+ while pos < len(text) and depth > 0:
961
+ char = text[pos]
962
+ if in_string:
963
+ if char == "\\" and pos + 1 < len(text):
964
+ pos += 2
965
+ continue
966
+ elif char == string_char:
967
+ in_string = False
968
+ else:
969
+ if char in ('"', "'"):
970
+ in_string = True
971
+ string_char = char
972
+ elif char == "(":
973
+ depth += 1
974
+ elif char == ")":
975
+ depth -= 1
976
+ pos += 1
977
+
978
+ if depth != 0:
979
+ return None
980
+
981
+ return pos - 1
982
+
983
+ def _parse_set_function_call(
984
+ self, line: str
985
+ ) -> Optional[tuple[str, list[str], str, list[str], str]]:
986
+ """
987
+ Parse a line of the form:
988
+ <prefix>set a, b = func(arg1, arg2);
989
+ Returns (prefix, result_vars, func_name, args, suffix) or None.
990
+ """
991
+ for match in re.finditer(r"\bset\s+", line):
992
+ prefix = line[: match.start()]
993
+ rest = line[match.end() :]
994
+
995
+ if "=" not in rest:
996
+ continue
997
+
998
+ lhs, rhs = rest.split("=", 1)
999
+ lhs = lhs.strip()
1000
+ rhs = rhs.lstrip()
1001
+
1002
+ if not lhs:
1003
+ continue
1004
+
1005
+ func_match = re.match(r"(\w+)\s*\(", rhs)
1006
+ if not func_match:
1007
+ continue
1008
+
1009
+ func_name = func_match.group(1)
1010
+ open_paren_index = func_match.end() - 1
1011
+ close_paren_index = self._find_matching_paren(rhs, open_paren_index)
1012
+ if close_paren_index is None:
1013
+ continue
1014
+
1015
+ args_str = rhs[open_paren_index + 1 : close_paren_index]
1016
+ tail = rhs[close_paren_index + 1 :]
1017
+
1018
+ semicolon_match = re.match(r"\s*;(?P<suffix>.*)", tail, flags=re.DOTALL)
1019
+ if not semicolon_match:
1020
+ continue
1021
+
1022
+ suffix = semicolon_match.group("suffix")
1023
+
1024
+ # Split result vars (tuple unpacking if comma-separated)
1025
+ result_vars = [v.strip() for v in lhs.split(",")] if "," in lhs else [lhs]
1026
+ if any(not v for v in result_vars):
1027
+ continue
1028
+
1029
+ args = []
1030
+ if args_str.strip():
1031
+ args = self._parse_macro_args(args_str)
1032
+
1033
+ return prefix, result_vars, func_name, args, suffix
1034
+
1035
+ return None
1036
+
1037
+ def _process_function_calls(self, line: str) -> str:
1038
+ """Replace function calls with VCL subroutine calls using globals."""
1039
+ # First, expand any macros in the line (NEW in v2.4)
1040
+ line = self._expand_macros(line)
1041
+
1042
+ parsed = self._parse_set_function_call(line)
1043
+ if not parsed:
1044
+ return line
1045
+
1046
+ prefix, result_vars, func_name, args, suffix = parsed
1047
+
1048
+ if func_name not in self.functions:
1049
+ return line
1050
+
1051
+ func = self.functions[func_name]
1052
+ return_types = func.get_return_types()
1053
+
1054
+ # Tuple assignment
1055
+ if len(result_vars) > 1:
1056
+ if not func.is_tuple_return():
1057
+ return line
1058
+
1059
+ if len(result_vars) != len(return_types):
1060
+ raise self.make_error(
1061
+ f"Function {func_name} returns {len(return_types)} values, "
1062
+ f"but {len(result_vars)} variables provided"
1063
+ )
1064
+
1065
+ if len(args) != len(func.params):
1066
+ raise self.make_error(
1067
+ f"Function {func_name} expects {len(func.params)} arguments, got {len(args)}"
1068
+ )
1069
+
1070
+ result_lines = []
1071
+ for (param_name, param_type), arg in zip(func.params, args):
1072
+ global_name = func.get_param_global(param_name)
1073
+ result_lines.extend(self._param_to_global(prefix, global_name, arg, param_type))
1074
+
1075
+ result_lines.append(f"{prefix}call {func_name};")
1076
+
1077
+ for idx, (result_var, ret_type) in enumerate(zip(result_vars, return_types)):
1078
+ return_global = func.get_return_global(idx)
1079
+ result_lines.extend(
1080
+ self._global_to_var(prefix, result_var, return_global, ret_type)
1081
+ )
1082
+
1083
+ if suffix:
1084
+ result_lines[-1] = f"{result_lines[-1]}{suffix}"
1085
+
1086
+ return "\n".join(result_lines)
1087
+
1088
+ # Single assignment
1089
+ if func.is_tuple_return():
1090
+ return line
1091
+
1092
+ if len(args) != len(func.params):
1093
+ raise self.make_error(
1094
+ f"Function {func_name} expects {len(func.params)} arguments, got {len(args)}"
1095
+ )
1096
+
1097
+ result_lines = []
1098
+ for (param_name, param_type), arg in zip(func.params, args):
1099
+ global_name = func.get_param_global(param_name)
1100
+ result_lines.extend(self._param_to_global(prefix, global_name, arg, param_type))
1101
+
1102
+ result_lines.append(f"{prefix}call {func_name};")
1103
+
1104
+ return_global = func.get_return_global()
1105
+ result_lines.extend(
1106
+ self._global_to_var(prefix, result_vars[0], return_global, return_types[0])
1107
+ )
1108
+
1109
+ if suffix:
1110
+ result_lines[-1] = f"{result_lines[-1]}{suffix}"
1111
+
1112
+ return "\n".join(result_lines)
1113
+
1114
+ def _expand_macros(self, line: str) -> str:
1115
+ """Expand all macro calls in a line."""
1116
+ # Keep expanding until no more macros found (handle nested macros)
1117
+ max_iterations = 10 # Prevent infinite loops
1118
+ iteration = 0
1119
+
1120
+ while iteration < max_iterations:
1121
+ new_line = self._expand_macros_once(line)
1122
+ if new_line == line:
1123
+ break # No more macros to expand
1124
+ line = new_line
1125
+ iteration += 1
1126
+
1127
+ if iteration >= max_iterations:
1128
+ raise self.make_error("Too many macro expansion iterations (possible recursive macros)")
1129
+
1130
+ return line
1131
+
1132
+ def _expand_macros_once(self, line: str) -> str:
1133
+ """Expand macros in a line once (one pass). Expand leftmost macro first."""
1134
+ # Find potential macro calls by looking for identifier followed by (
1135
+ pattern = r"\b(\w+)\s*\("
1136
+
1137
+ for match in re.finditer(pattern, line):
1138
+ macro_name = match.group(1)
1139
+
1140
+ # Check if this is a macro
1141
+ if macro_name not in self.macros:
1142
+ continue
1143
+
1144
+ # Find the matching closing parenthesis (ignoring parens inside strings)
1145
+ start_pos = match.end() # Position after the opening (
1146
+ paren_depth = 1
1147
+ pos = start_pos
1148
+ in_string = False
1149
+ string_char = None
1150
+
1151
+ while pos < len(line) and paren_depth > 0:
1152
+ char = line[pos]
1153
+ if in_string:
1154
+ if char == "\\" and pos + 1 < len(line):
1155
+ # Skip escaped character
1156
+ pos += 2
1157
+ continue
1158
+ elif char == string_char:
1159
+ in_string = False
1160
+ else:
1161
+ if char in ('"', "'"):
1162
+ in_string = True
1163
+ string_char = char
1164
+ elif char == "(":
1165
+ paren_depth += 1
1166
+ elif char == ")":
1167
+ paren_depth -= 1
1168
+ pos += 1
1169
+
1170
+ if paren_depth != 0:
1171
+ # Unmatched parentheses - skip this match
1172
+ continue
1173
+
1174
+ # Extract arguments string (between parentheses)
1175
+ args_str = line[start_pos : pos - 1]
1176
+
1177
+ # Parse arguments
1178
+ args = []
1179
+ if args_str.strip():
1180
+ args = self._parse_macro_args(args_str)
1181
+
1182
+ # Expand the macro
1183
+ macro = self.macros[macro_name]
1184
+ try:
1185
+ expanded = macro.expand(args)
1186
+ self.log_debug(f"Expanded macro {macro_name}({args_str}) -> {expanded}", indent=3)
1187
+ except ValueError as e:
1188
+ raise self.make_error(str(e))
1189
+
1190
+ # Build result with the macro replaced
1191
+ result = line[: match.start()] + expanded + line[pos:]
1192
+ return result
1193
+
1194
+ # No macros found
1195
+ return line
1196
+
1197
+ def _parse_macro_args(self, args_str: str) -> list[str]:
1198
+ """Parse macro arguments, handling nested parentheses and strings."""
1199
+ args = []
1200
+ current_arg = []
1201
+ depth = 0
1202
+ in_string = False
1203
+ string_char = None
1204
+ i = 0
1205
+
1206
+ while i < len(args_str):
1207
+ char = args_str[i]
1208
+
1209
+ if in_string:
1210
+ current_arg.append(char)
1211
+ if char == "\\" and i + 1 < len(args_str):
1212
+ # Include escaped character
1213
+ current_arg.append(args_str[i + 1])
1214
+ i += 2
1215
+ continue
1216
+ elif char == string_char:
1217
+ in_string = False
1218
+ else:
1219
+ if char in ('"', "'"):
1220
+ in_string = True
1221
+ string_char = char
1222
+ current_arg.append(char)
1223
+ elif char == "(":
1224
+ depth += 1
1225
+ current_arg.append(char)
1226
+ elif char == ")":
1227
+ depth -= 1
1228
+ current_arg.append(char)
1229
+ elif char == "," and depth == 0:
1230
+ # End of current argument
1231
+ args.append("".join(current_arg).strip())
1232
+ current_arg = []
1233
+ else:
1234
+ current_arg.append(char)
1235
+ i += 1
1236
+
1237
+ # Add last argument
1238
+ if current_arg:
1239
+ args.append("".join(current_arg).strip())
1240
+
1241
+ return args
1242
+
1243
+ def _param_to_global(
1244
+ self, prefix: str, global_name: str, arg: str, param_type: str
1245
+ ) -> list[str]:
1246
+ """Convert parameter to global with type conversion."""
1247
+ lines = []
1248
+ if param_type == "INTEGER":
1249
+ lines.append(f"{prefix}set {global_name} = std.itoa({arg});")
1250
+ elif param_type == "FLOAT":
1251
+ lines.append(f'{prefix}set {global_name} = "" + {arg};')
1252
+ elif param_type == "BOOL":
1253
+ lines.append(f"{prefix}if ({arg}) {{")
1254
+ lines.append(f'{prefix} set {global_name} = "true";')
1255
+ lines.append(f"{prefix}}} else {{")
1256
+ lines.append(f'{prefix} set {global_name} = "false";')
1257
+ lines.append(f"{prefix}}}")
1258
+ else:
1259
+ lines.append(f"{prefix}set {global_name} = {arg};")
1260
+ return lines
1261
+
1262
+ def _global_to_var(
1263
+ self, prefix: str, result_var: str, return_global: str, ret_type: str
1264
+ ) -> list[str]:
1265
+ """Convert global to variable with type conversion."""
1266
+ lines = []
1267
+ if ret_type == "INTEGER":
1268
+ lines.append(f"{prefix}set {result_var} = std.atoi({return_global});")
1269
+ elif ret_type == "FLOAT":
1270
+ lines.append(f"{prefix}set {result_var} = std.atof({return_global});")
1271
+ elif ret_type == "BOOL":
1272
+ lines.append(f'{prefix}set {result_var} = ({return_global} == "true");')
1273
+ else:
1274
+ lines.append(f"{prefix}set {result_var} = {return_global};")
1275
+ return lines
1276
+
1277
+ def _process_for_loop(
1278
+ self, lines: list[str], start: int, end: int, context: dict[str, Any]
1279
+ ) -> int:
1280
+ """Process a #for loop with optional tuple unpacking."""
1281
+ line = lines[start].strip()
1282
+
1283
+ match = re.match(r"#for\s+(\w+(?:\s*,\s*\w+)*)\s+in\s+(.+)", line)
1284
+ if not match:
1285
+ raise self.make_error(f"Invalid #for syntax: {line}")
1286
+
1287
+ vars_str = match.group(1).strip()
1288
+ iterable_expr = match.group(2)
1289
+
1290
+ var_names = [v.strip() for v in vars_str.split(",")] if "," in vars_str else [vars_str]
1291
+
1292
+ for var_name in var_names:
1293
+ if not re.match(r"^\w+$", var_name):
1294
+ raise self.make_error(f"Invalid variable name in #for: '{var_name}'")
1295
+
1296
+ try:
1297
+ iterable = self._evaluate_expression(iterable_expr, context)
1298
+ except Exception as e:
1299
+ raise self.make_error(f"Error evaluating loop expression '{iterable_expr}': {e}")
1300
+
1301
+ try:
1302
+ loop_end = self._find_matching_end(lines, start, end, "#for", "#endfor")
1303
+ except SyntaxError as e:
1304
+ raise self.make_error(str(e))
1305
+
1306
+ iterable = list(iterable)
1307
+ self.log_debug(f"Loop iterating {len(iterable)} times", indent=2)
1308
+
1309
+ for idx, value in enumerate(iterable):
1310
+ loop_context = context.copy()
1311
+
1312
+ if len(var_names) == 1:
1313
+ loop_context[var_names[0]] = value
1314
+ self.log_debug(f"Iteration {idx}: {var_names[0]} = {value}", indent=3)
1315
+ else:
1316
+ try:
1317
+ values = tuple(value)
1318
+ except TypeError:
1319
+ raise self.make_error(
1320
+ f"Cannot unpack non-iterable value '{value}' into {len(var_names)} variables"
1321
+ )
1322
+
1323
+ if len(values) != len(var_names):
1324
+ raise self.make_error(
1325
+ f"Cannot unpack {len(values)} values into {len(var_names)} variables "
1326
+ f"({', '.join(var_names)})"
1327
+ )
1328
+
1329
+ for var_name, val in zip(var_names, values):
1330
+ loop_context[var_name] = val
1331
+
1332
+ self.log_debug(
1333
+ f"Iteration {idx}: {', '.join(f'{n}={v}' for n, v in zip(var_names, values))}",
1334
+ indent=3,
1335
+ )
1336
+
1337
+ self._process_lines(lines, start + 1, loop_end, loop_context)
1338
+
1339
+ return loop_end + 1
1340
+
1341
+ def _process_if(self, lines: list[str], start: int, end: int, context: dict[str, Any]) -> int:
1342
+ """Process a #if conditional."""
1343
+ line = lines[start].strip()
1344
+
1345
+ match = re.match(r"#if\s+(.+)", line)
1346
+ if not match:
1347
+ raise self.make_error(f"Invalid #if syntax: {line}")
1348
+
1349
+ condition = match.group(1)
1350
+
1351
+ try:
1352
+ result = self._evaluate_expression(condition, context)
1353
+ except Exception as e:
1354
+ raise self.make_error(f"Error evaluating condition '{condition}': {e}")
1355
+
1356
+ self.log_debug(f"Condition '{condition}' evaluated to {result}", indent=2)
1357
+
1358
+ else_idx = None
1359
+ try:
1360
+ endif_idx = self._find_matching_end(lines, start, end, "#if", "#endif")
1361
+ except SyntaxError as e:
1362
+ raise self.make_error(str(e))
1363
+
1364
+ depth = 0
1365
+ for i in range(start, endif_idx):
1366
+ stripped = lines[i].strip()
1367
+ if stripped.startswith("#if"):
1368
+ depth += 1
1369
+ elif stripped == "#endif":
1370
+ depth -= 1
1371
+ elif stripped == "#else" and depth == 1:
1372
+ else_idx = i
1373
+ break
1374
+
1375
+ if result:
1376
+ branch_end = else_idx if else_idx else endif_idx
1377
+ self.log_debug("Taking if branch", indent=2)
1378
+ self._process_lines(lines, start + 1, branch_end, context)
1379
+ else:
1380
+ if else_idx:
1381
+ self.log_debug("Taking else branch", indent=2)
1382
+ self._process_lines(lines, else_idx + 1, endif_idx, context)
1383
+ else:
1384
+ self.log_debug("Skipping if block", indent=2)
1385
+
1386
+ return endif_idx + 1
1387
+
1388
+ def _find_matching_end(
1389
+ self, lines: list[str], start: int, end: int, open_keyword: str, close_keyword: str
1390
+ ) -> int:
1391
+ """Find the matching closing keyword for a block."""
1392
+ depth = 0
1393
+ for i in range(start, end):
1394
+ stripped = lines[i].strip()
1395
+ # Check for open keyword with word boundary (followed by space or end of line)
1396
+ if stripped == open_keyword or stripped.startswith(open_keyword + " "):
1397
+ depth += 1
1398
+ elif stripped == close_keyword:
1399
+ depth -= 1
1400
+ if depth == 0:
1401
+ return i
1402
+
1403
+ raise SyntaxError(f"No matching {close_keyword} for {open_keyword} at line {start + 1}")
1404
+
1405
+ def _substitute_expressions(self, line: str, context: dict[str, Any]) -> str:
1406
+ """Substitute {{expression}} in a line."""
1407
+
1408
+ def replace_expr(match):
1409
+ expr = match.group(1)
1410
+ try:
1411
+ value = self._evaluate_expression(expr, context)
1412
+ except Exception as e:
1413
+ raise self.make_error(f"Error evaluating expression '{expr}': {e}")
1414
+ return str(value)
1415
+
1416
+ return re.sub(r"\{\{(.+?)\}\}", replace_expr, line)
1417
+
1418
+ def _evaluate_expression(self, expr: str, context: dict[str, Any]) -> Any:
1419
+ """Safely evaluate an expression in the given context."""
1420
+ try:
1421
+ safe_globals = {
1422
+ "range": range,
1423
+ "len": len,
1424
+ "str": str,
1425
+ "int": int,
1426
+ "hex": hex,
1427
+ "format": format,
1428
+ "abs": abs,
1429
+ "min": min,
1430
+ "max": max,
1431
+ "enumerate": enumerate,
1432
+ # Boolean literals (both Python and C-style)
1433
+ "True": True,
1434
+ "False": False,
1435
+ "true": True,
1436
+ "false": False,
1437
+ }
1438
+
1439
+ # Merge constants into context
1440
+ eval_env = {**safe_globals, **self.constants, **context}
1441
+ result = eval(expr, {"__builtins__": {}}, eval_env)
1442
+
1443
+ return result
1444
+ except NameError as e:
1445
+ # Provide helpful suggestions
1446
+ var_name = str(e).split("'")[1] if "'" in str(e) else ""
1447
+ available_names = (
1448
+ list(safe_globals.keys()) + list(self.constants.keys()) + list(context.keys())
1449
+ )
1450
+ suggestions = get_close_matches(var_name, available_names, n=3, cutoff=0.6)
1451
+
1452
+ error_msg = f"Name '{var_name}' is not defined"
1453
+ if suggestions:
1454
+ error_msg += f"\n Did you mean: {', '.join(suggestions)}?"
1455
+ error_msg += f"\n Available: {', '.join(sorted(available_names))}"
1456
+
1457
+ raise NameError(error_msg)
1458
+ except Exception as e:
1459
+ raise ValueError(f"Error evaluating expression '{expr}': {e}")
1460
+
1461
+
1462
+ def main():
1463
+ """Main entry point."""
1464
+ parser = argparse.ArgumentParser(
1465
+ description="xvcl - Extended VCL compiler with metaprogramming features",
1466
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1467
+ epilog="""
1468
+ Features:
1469
+ - For loops: #for var in range(n)
1470
+ - Conditionals: #if condition
1471
+ - Templates: {{expression}}
1472
+ - Constants: #const NAME TYPE = value
1473
+ - Includes: #include "path/to/file.xvcl"
1474
+ - Inline macros: #inline name(params) ... #endinline
1475
+ - Functions: #def name(params) -> TYPE
1476
+ - Variables: #let name TYPE = expression;
1477
+
1478
+ Example:
1479
+ xvcl input.xvcl -o output.vcl
1480
+ xvcl input.xvcl -o output.vcl --debug
1481
+ xvcl input.xvcl -o output.vcl -I /path/to/includes
1482
+ """,
1483
+ )
1484
+
1485
+ parser.add_argument("input", help="Input XVCL file")
1486
+ parser.add_argument("-o", "--output", help="Output VCL file (default: removes .xvcl extension)")
1487
+ parser.add_argument(
1488
+ "-I",
1489
+ "--include",
1490
+ dest="include_paths",
1491
+ action="append",
1492
+ help="Add include search path (can be specified multiple times)",
1493
+ )
1494
+ parser.add_argument("--debug", action="store_true", help="Enable debug mode (verbose output)")
1495
+ parser.add_argument(
1496
+ "--source-maps", action="store_true", help="Add source map comments to generated code"
1497
+ )
1498
+ parser.add_argument(
1499
+ "-v", "--verbose", action="store_true", help="Verbose output (alias for --debug)"
1500
+ )
1501
+
1502
+ args = parser.parse_args()
1503
+
1504
+ # Determine output path
1505
+ if args.output:
1506
+ output_path = args.output
1507
+ elif args.input.endswith(".xvcl"):
1508
+ output_path = args.input.replace(".xvcl", ".vcl")
1509
+ else:
1510
+ output_path = args.input + ".vcl"
1511
+
1512
+ # Set up include paths
1513
+ include_paths = args.include_paths or ["."]
1514
+
1515
+ # Enable debug if verbose flag is used
1516
+ debug = args.debug or args.verbose
1517
+
1518
+ try:
1519
+ compiler = XVCLCompiler(
1520
+ include_paths=include_paths, debug=debug, source_maps=args.source_maps
1521
+ )
1522
+ compiler.process_file(args.input, output_path)
1523
+
1524
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ Compilation complete{Colors.RESET}")
1525
+
1526
+ except PreprocessorError as e:
1527
+ print(e.format_error(use_colors=True), file=sys.stderr)
1528
+ sys.exit(1)
1529
+ except FileNotFoundError as e:
1530
+ print(f"{Colors.RED}Error:{Colors.RESET} {e}", file=sys.stderr)
1531
+ sys.exit(1)
1532
+ except Exception as e:
1533
+ print(f"{Colors.RED}Unexpected error:{Colors.RESET} {e}", file=sys.stderr)
1534
+ import traceback
1535
+
1536
+ traceback.print_exc()
1537
+ sys.exit(1)
1538
+
1539
+
1540
+ if __name__ == "__main__":
1541
+ main()