# Plan: Improve xvcl Warnings and Errors

Goal: make xvcl errors clear enough that an LLM agent can read them, understand the problem, and fix the source `.xvcl` file without human help. Also catch common mistakes that would be rejected by Falco, so users get feedback at the xvcl source level instead of chasing generated VCL line numbers.

## Design Principles

- Every diagnostic must say **what went wrong**, **where** (file:line with include chain), and **how to fix it**.
- Use a structured, parseable format: `error[rule-id]: message` (inspired by rustc/clippy).
- Show the offending source line with a caret pointing at the problem.
- Distinguish **errors** (compilation fails) from **warnings** (output produced but likely wrong). Add `--strict` to promote warnings to errors.
- Keep the existing "did you mean?" suggestions and expand them to more situations.

---

## Phase 0: Diagnostic Model and Source Provenance

This is the foundation. All later phases depend on it.

### 0.1 Introduce a `Diagnostic` data class

Replace ad-hoc string messages with a structured model. Currently `PreprocessorError` carries `message`, `location`, `context_lines` — and some bad states raise raw `ValueError` (e.g., `compiler.py:739` during function return-value generation), bypassing the structured path entirely.

```python
@dataclass
class Span:
    file: str
    line: int
    col_start: int | None = None  # optional, for caret pointing
    col_end: int | None = None

@dataclass
class Diagnostic:
    rule: str              # e.g. "undefined-function", "invalid-const"
    severity: str          # "error" or "warning"
    message: str           # human-readable summary
    span: Span             # primary location
    source_line: str       # the actual source text at that line
    help: str | None       # actionable fix suggestion
    notes: list[str]       # additional context ("defined at ...", "included from ...")
    suggestions: list[str] # did-you-mean candidates
```

`PreprocessorError` keeps existing behavior but wraps a `Diagnostic`. Rendering (text vs JSON) becomes a method on `Diagnostic`, not on the error class. This way `format_error()` and a future `to_json()` both read the same data — no logic duplication when implementing JSON output later.

All raw `ValueError` / `SyntaxError` raises that currently bypass the diagnostic path (function generation at line 739, `_find_matching_end` at line 1461) must be converted to `PreprocessorError` with proper `Diagnostic` payloads.

### 0.2 Per-line source provenance

**Problem:** After `_process_includes()` flattens included content (line 374), later passes run with `current_file`/`current_lines` from the main file. Errors in macros, functions, or directives extracted from included files get misattributed to wrong file/line.

**Fix:** Introduce a `SourceLine` type that tags every line with its origin:

```python
@dataclass
class SourceSegment:
    file: str
    line: int
    col_start: int
    col_end: int

@dataclass
class SourceLine:
    text: str
    segments: list[SourceSegment]  # one per physical origin line
    included_from: list[tuple[str, int]]  # include chain: [(file, line), ...]
```

A simple line has one segment. When `_join_multiline_directives()` (line 900) or `_join_multiline_calls()` (line 947) merge multiple physical lines into one logical line, the resulting `SourceLine` carries multiple `SourceSegment` entries — one per original line. This preserves correct caret placement: the diagnostic renderer maps character offsets in the joined text back to the right physical line and column via the segment list.

`_process_includes()` wraps each included line in `SourceLine` with the correct origin and builds the `included_from` chain. All subsequent passes operate on `list[SourceLine]` instead of `list[str]`. When an error is raised, `make_error()` reads provenance from the `SourceLine` to produce correct file:line and an "included from" note:

```
error[invalid-const]: Invalid #const declaration
  --> includes/config.xvcl:12
   |
12 |   #const BAD
   |   ^^^^^^^^^^
   = note: included from main.xvcl:3
```

This is more work than a simple "included from" annotation — it's structural tracking through all passes. But without it, every error from included content is misleading.

### 0.3 Collect diagnostics, don't fail on first error

Add a `self.diagnostics: list[Diagnostic]` to the compiler. Extraction passes (1-4) append diagnostics instead of raising immediately. After each pass, if any error-severity diagnostics exist, stop and report them all. Cap at ~10 to avoid noise cascades.

This saves round-trips for LLM agents that fix errors iteratively — one compilation run surfaces multiple problems.

For passes that can't continue after an error (e.g., `_process_lines` where the line count changes), keep the fail-fast behavior but wrap in a `Diagnostic`.

---

## Phase 1: Fix Silent Failures

These are cases where invalid input produces broken VCL with no indication of what went wrong.

### 1.1 Undefined function calls in `set` assignments

**Current behavior:** `_process_function_calls()` (line 1095) parses `set ... = func(...)` shapes. If `func_name not in self.functions` (line 1106), it silently returns the line unchanged. The generated VCL then references a nonexistent function, and Falco reports `call-statement/subroutine-notfound` with no connection to the xvcl source.

**Scope limitation:** The parser only recognizes assignment-style calls (`set var.x = func(args);`). Standalone calls like `func(args);` or calls inside expressions are not parsed. This fix only covers the `set` assignment pattern — a broader expression-aware call detector is deferred to Phase 5.

**Fix:** When `_parse_set_function_call()` finds a call pattern and the name is not in `self.functions` AND not in `self.macros`, check if it looks like a user-defined function (not a VCL builtin like `regsub`, `std.tolower`, etc.) by maintaining a set of known VCL function prefixes (`std.`, `digest.`, `regsub`, `regsuball`, `table.lookup`, etc.). If the name is unknown:

```
error[undefined-function]: Function 'addd' is not defined
  --> input.xvcl:42
   |
42 |   set var.x = addd(1, 2);
   |               ^^^^
   = did you mean: 'add'?
   = defined functions: add, multiply, normalize
```

### 1.2 Tuple return assigned to single variable (and vice versa)

**Current behavior:** If a function returns a tuple but the call site uses single assignment (line 1147: `if func.is_tuple_return(): return line`), the line passes through silently. Same for assigning multiple variables to a single-return function (line 1114).

**Fix:** Both paths should raise errors:

```
error[return-count-mismatch]: Function 'parse' returns 2 values but assigned to 1 variable
  --> input.xvcl:30
   |
30 |   set var.x = parse("hello");
   |               ^^^^^
   = help: use tuple unpacking: set var.a, var.b = parse("hello");
   = note: 'parse' returns (STRING, INTEGER), defined at lib.xvcl:5
```

### 1.3 Missing return statement in functions with return type

**Current behavior:** A `#def` with a declared return type but no `return` statement compiles silently. The generated subroutine never sets `req.http.X-Func-*-Return`, so the caller reads an empty/stale header.

**Fix:** After extracting the function body, scan for at least one `return` statement if the function has a return type. Emit an error (not warning — the generated VCL is silently broken):

```
error[missing-return]: Function 'calculate' declares return type INTEGER but has no return statement
  --> input.xvcl:10
   |
10 |   #def calculate(x INTEGER) -> INTEGER
   |                                ^^^^^^^
   = help: add 'return <expression>;' to the function body
```

### 1.4 Raw ValueError in function generation

**Current behavior:** `_generate_function_sub()` at line 739 raises a bare `ValueError` when return expression count doesn't match return types. This bypasses the diagnostic path entirely — the user sees a Python traceback or a generic error.

**Fix:** Convert to `PreprocessorError` with proper diagnostics, including the function definition location and expected vs actual return count.

---

## Phase 2: Improve Existing Error Messages

All existing `make_error()` calls get converted to produce `Diagnostic` objects with `rule`, `help`, and `suggestions` fields.

### 2.1 `#const` errors

Current: `"Invalid #const syntax: {stripped}"`

```
error[invalid-const]: Invalid #const declaration
  --> input.xvcl:5
   |
 5 |   #const PORT 8080
   |          ^^^^^^^^^
   = help: expected '#const NAME = value' or '#const NAME TYPE = value'
   = example: #const PORT INTEGER = 8080
```

### 2.2 `#for` errors

Current: `"Invalid #for syntax: {line}"`

```
error[invalid-for]: Invalid #for loop syntax
  --> input.xvcl:10
   |
10 |   #for in range(10)
   |   ^^^^^^^^^^^^^^^^^
   = help: expected '#for VARIABLE in EXPRESSION'
   = example: #for i in range(10)
```

### 2.3 Expression evaluation errors

Current: `"Error evaluating expression '{expr}': {e}"`

Decompose into subcases:
- **Undefined name:** Already has did-you-mean; group available names by category (constants, loop variables, builtins).
- **Division by zero:** Detect and explain.
- **Type error:** e.g., `"hello" + 5` — show what types are involved.
- **Syntax error:** Show the expression with a pointer.

### 2.4 Unclosed block errors

Current: `"No matching #endfor for #for at line 10"`

```
error[unclosed-block]: Unclosed #for block
  --> input.xvcl:10
   |
10 |   #for i in range(5)
   |   ^^^^^^^^^^^^^^^^^^
   = help: add '#endfor' to close this block
```

### 2.5 Argument count mismatch

Current: `"Function {name} expects {n} arguments, got {m}"`

```
error[arg-count]: Wrong number of arguments for function 'add'
  --> input.xvcl:20
   |
20 |   set var.x = add(1, 2, 3);
   |               ^^^^^^^^^^^
   = expected 2 arguments (a INTEGER, b INTEGER), got 3
   = note: defined at lib.xvcl:5
```

Show the function signature and definition location.

### 2.6 Duplicate definition errors

Current: `"Function 'foo' already defined at lib.xvcl:5"`

```
error[duplicate-def]: Function 'foo' is already defined
  --> input.xvcl:20
   |
20 |   #def foo(x INTEGER) -> INTEGER
   |        ^^^
   = note: previously defined at lib.xvcl:5
   = help: rename this function or remove the duplicate
```

---

## Phase 3: New Diagnostics for Common VCL Mistakes

Catch patterns that Falco would reject, reported at xvcl source-level.

### 3.1 Invalid VCL type names

**Where:** `#def` parameters/returns, `#let`, `#const` with explicit type.

**Two distinct checks:**

1. **`invalid-vcl-type`**: The name is not a recognized VCL type at all. Falco recognizes: `STRING`, `INTEGER`, `FLOAT`, `BOOL`, `RTIME`, `TIME`, `BACKEND`, `IP`, `ACL`, `REGEX`. Error with did-you-mean.

2. **`unsupported-bridge-type`**: The type is a valid VCL type but xvcl's function bridge (`_param_to_global` / `_global_to_var`) only handles `STRING`, `INTEGER`, `FLOAT`, `BOOL`. Using e.g. `RTIME` or `IP` in a `#def` parameter generates broken conversion code — the bridge falls back to string passthrough (line 1301, 1320), which produces actually invalid VCL, not "likely wrong" output. This is an **error**, not a warning:

```
error[unsupported-bridge-type]: Type 'RTIME' is not supported in xvcl function parameters
  --> input.xvcl:5
   |
 5 |   #def timeout(t RTIME) -> RTIME
   |                  ^^^^^
   = note: xvcl function parameter passing converts through STRING headers
   = note: supported types for #def: STRING, INTEGER, FLOAT, BOOL
   = help: use STRING and convert manually, or use an #inline macro instead
```

### 3.2 Duplicate `#const` redefinition

**Current:** Silently overwrites. This is an **error**, not a warning, because it changes compile-time evaluation state — later expressions using the constant get a different value with no indication of which definition won.

```
error[const-redefined]: Constant 'PORT' is already defined
  --> input.xvcl:10
   |
10 |   #const PORT = 9090
   |          ^^^^
   = note: previously defined as 8080 at input.xvcl:3
```

### 3.3 Duplicate generated names from loops

When `#for` loops generate backends, subroutines, tables, or other named VCL declarations, check that expanded names don't collide:

```
error[duplicate-generated]: Loop generates duplicate backend name 'F_web_1'
  --> input.xvcl:3
   |
 3 |   #for name in NAMES
   |   ^^^^^^^^^^^^^^^^^^
   = iteration 0 (name="web-1") and iteration 1 (name="web_1") both produce 'F_web_1'
```

Implementation: maintain a compiler-wide declaration table of generated VCL names (backends, subroutines, tables, ACLs). After expanding any `#for` body, scan output lines for `backend NAME`, `sub NAME`, `table NAME` patterns and check for collisions against the full table — not just within the current loop. This catches collisions between different loops, between a loop and declarations before/after it, and within a single loop.

### 3.4 Unused definitions

Warn when a `#const`, `#inline`, or `#def` is defined but never referenced. Catches typos where the user defined `normalize_host` but called `normalise_host`.

```
warning[unused-def]: Macro 'normalize_host' is defined but never used
  --> lib.xvcl:5
   |
 5 |   #inline normalize_host(host)
   |           ^^^^^^^^^^^^^^
```

Track usage during expansion passes. Report at end of compilation.

---

## Phase 4: Machine-Readable Output

### 4.1 JSON error output

Add `--error-format=json` flag. The `Diagnostic` model from Phase 0 renders directly:

```json
{
  "diagnostics": [
    {
      "rule": "undefined-function",
      "severity": "error",
      "message": "Function 'addd' is not defined",
      "file": "input.xvcl",
      "line": 42,
      "column": 15,
      "source_line": "  set var.x = addd(1, 2);",
      "help": "did you mean: 'add'?",
      "suggestions": ["add"],
      "notes": [],
      "included_from": []
    }
  ]
}
```

Because the `Diagnostic` model exists from Phase 0, this is just a new renderer — no logic duplication.

### 4.2 Text rendering improvements

Update the text renderer (replacing current `format_error()`) to produce the rustc-style output with carets, using `Span.col_start`/`col_end` when available:

```
error[invalid-const]: Invalid #const declaration
  --> input.xvcl:5
   |
 5 |   #const PORT 8080
   |          ^^^^^^^^^
   = help: expected '#const NAME = value' or '#const NAME TYPE = value'
```

---

## Phase 5: Deeper VCL-Aware Checks (Future)

Lower priority. Requires xvcl to understand more VCL semantics.

### 5.1 Broader function/macro call detection

Currently `_process_function_calls()` only parses `set ... = func(...)`. Other call sites — standalone `func(args);`, calls inside `if` conditions, nested in expressions — are not detected. Adding a broader expression-aware pass would catch more undefined-function errors, but requires careful filtering against VCL builtins to avoid false positives.

### 5.2 VCL symbol table

Track backends, tables, subroutines, and variables declared in the output. Warn when:
- `set req.backend = NAME;` references an undefined backend
- `table.lookup(NAME, ...)` references an undefined table
- `call NAME;` references an undefined subroutine

### 5.3 Table item count limit

If a `#for` loop generates a table with more than 1000 entries, warn (Fastly hard limit).

### 5.4 Scope annotations

Emit `// @scope:` annotations on generated subroutines so Falco can validate scope correctness.

---

## Testing Strategy

Each phase must land with tests. Use the existing fixture-based pattern (`tests/xvcl/error_*.xvcl` with `// ERROR:` first-line convention).

### Critical regression areas

1. **Merged-line provenance (Phase 0).** Add tests that combine includes with multiline joins — e.g., an included file containing a `#const` whose value spans multiple lines, or a function call split across lines inside an included file. Verify that the diagnostic points at the correct origin file, line, and column. This is the easiest place to regress caret accuracy.

2. **Unsupported bridge types (Phase 1 / 3.1).** Test both parameter and return positions, since the failure comes from both `_param_to_global` (line 1301) and `_global_to_var` (line 1320). Cover at least `RTIME`, `IP`, and `BACKEND` in each position.

3. **Deterministic diagnostic ordering (Phase 0.3).** Once multi-error collection lands, diagnostics must be emitted in source order (by file, then line number). Pin this with a test that has multiple errors and asserts exact output order. Otherwise snapshot-style tests become flaky.

### Test file conventions

- `tests/xvcl/error_<rule_id>.xvcl` — one file per error rule, first line `// ERROR: <expected substring>`
- `tests/xvcl/warning_<rule_id>.xvcl` — same pattern for warnings, first line `// WARNING: <expected substring>` (new convention)
- `tests/xvcl/error_multi_*.xvcl` — files with multiple intentional errors, to test multi-error collection and ordering
- `tests/expected_errors/<rule_id>.txt` — optional: full expected diagnostic output for snapshot testing (text format)
- `tests/expected_errors/<rule_id>.json` — optional: expected JSON diagnostic output

### What to test per phase

| Phase | Test focus |
|-------|-----------|
| 0.1 | `Diagnostic` renders identically in text and JSON for the same error |
| 0.2 | Provenance correct through includes, multiline joins, and both combined |
| 0.3 | Multiple errors collected in source order; cap at 10; no duplicate diagnostics |
| 1.1 | Undefined function in `set` assignment raises error with did-you-mean |
| 1.2 | Tuple/single return mismatch in both directions raises error with help text |
| 1.3 | Missing `return` in function with return type raises error |
| 1.4 | Mismatched return expression count raises `PreprocessorError`, not `ValueError` |
| 3.1 | Invalid type name (typo) errors; valid-but-unsupported type errors in param and return |
| 3.2 | Duplicate `#const` raises error with previous definition location |
| 3.3 | Duplicate generated names detected across loops and between loop/non-loop code |
| 4.1 | JSON output is valid JSON; fields match `Diagnostic` model; round-trips with text |

---

## Implementation Order

| Step | Task | Builds on | Effort |
|------|------|-----------|--------|
| **1** | `Diagnostic` + `Span` + `SourceLine` data model (0.1, 0.2) | — | Medium |
| **2** | Convert all existing error paths to emit `Diagnostic` (0.1) | Step 1 | Medium |
| **3** | Per-line source provenance through includes (0.2) | Step 1 | Medium |
| **4** | Fix silent failures: undefined functions, return mismatches, missing returns, raw ValueError (1.1-1.4) | Steps 1-2 | Small each |
| **5** | Multi-error collection in extraction passes (0.3) | Steps 1-3 | Medium |
| **6** | Improve all existing error messages with help/suggestions (2.1-2.6) | Steps 1-2 | Medium |
| **7** | New diagnostics: type validation, const redefinition, duplicate generated names (3.1-3.3) | Steps 1-3 | Small each |
| **8** | JSON output renderer (4.1) | Steps 1-2 | Small |
| **9** | Text renderer with carets (4.2) | Steps 1-2 | Small |
| **10** | Unused definition warnings (3.4) | Steps 1-5 | Medium |
| **11** | Broader call detection, VCL symbol table (5.1-5.4) | Steps 1-7 | Large |
