|
| 1 | +# pandoc.List() vs pandoc.Blocks() Bug Analysis |
| 2 | + |
| 3 | +## Executive Summary |
| 4 | + |
| 5 | +The bug fixed in manuscript.lua (PR #13624) is **NOT ConditionalBlock-specific**. It's a type mismatch bug where `pandoc.List()` is used instead of `pandoc.Blocks()` when building block collections that are assigned to element `.content` properties. ConditionalBlock merely **exposed** this bug, but the bug could be triggered by any code path that walks the AST of an element with wrongly-typed content. |
| 6 | + |
| 7 | +## The Core Bug: Type Mismatch |
| 8 | + |
| 9 | +### What's the Difference? |
| 10 | + |
| 11 | +From the Lua type definitions in quarto-cli: |
| 12 | + |
| 13 | +**pandoc.Blocks** (`src/resources/pandoc/blocks.lua:27-29`): |
| 14 | +```lua |
| 15 | +-- List of Block elements, a special case of a pandoc.List. |
| 16 | +-- pandoc.Blocks is a type alias for pandoc.List, but it also |
| 17 | +-- supports a `walk` method. |
| 18 | +``` |
| 19 | + |
| 20 | +**pandoc.List** (`src/resources/pandoc/List.lua`): |
| 21 | +- Standard Lua List implementation |
| 22 | +- Has methods like `:insert()`, `:extend()`, `:map()`, etc. |
| 23 | +- Does NOT have a `.walk()` method in the standard implementation |
| 24 | + |
| 25 | +### The Bug Pattern |
| 26 | + |
| 27 | +```lua |
| 28 | +-- BUGGY CODE |
| 29 | +local blocks = pandoc.List() |
| 30 | +for _, child in ipairs(divEl.content) do |
| 31 | + blocks:insert(child) |
| 32 | +end |
| 33 | +divEl.content = blocks -- BUG: Assigns List to .content |
| 34 | + |
| 35 | +-- Later in the filter chain... |
| 36 | +_quarto.ast.walk(divEl, {...}) -- CRASH: tries to walk divEl.content |
| 37 | +``` |
| 38 | + |
| 39 | +When you assign a `pandoc.List()` to `.content`, any subsequent AST walking operations that expect `pandoc.Blocks` (with its `.walk()` method) will fail. |
| 40 | + |
| 41 | +### Why It Crashes |
| 42 | + |
| 43 | +The crash happens when: |
| 44 | +1. Code assigns `pandoc.List()` to an element's `.content` property |
| 45 | +2. That element is returned from a filter or passed to another filter |
| 46 | +3. Some code tries to walk the AST, either: |
| 47 | + - Directly via `_quarto.ast.walk(element, ...)` |
| 48 | + - Indirectly when a filter returns `element.content` and Pandoc's filter system tries to process it |
| 49 | + - When custom filters traverse the AST |
| 50 | + |
| 51 | +The error manifests as attempting to call a method that doesn't exist on `pandoc.List`. |
| 52 | + |
| 53 | +## ConditionalBlock's Role: Exposing, Not Causing |
| 54 | + |
| 55 | +### What ConditionalBlock Does |
| 56 | + |
| 57 | +From `src/resources/filters/quarto-pre/content-hidden.lua:63`: |
| 58 | +```lua |
| 59 | +function conditionalBlock(show) |
| 60 | + return function(el) |
| 61 | + if show then |
| 62 | + return el.content -- Returns the Blocks directly |
| 63 | + else |
| 64 | + return {} |
| 65 | + end |
| 66 | + end |
| 67 | +end |
| 68 | +``` |
| 69 | + |
| 70 | +When `show` is true, ConditionalBlock returns `el.content` **as-is**. This propagates whatever type `.content` is up the filter chain. |
| 71 | + |
| 72 | +### How It Exposed the Bug |
| 73 | + |
| 74 | +1. manuscript.lua assigns `pandoc.List()` to `divEl.content` |
| 75 | +2. That `divEl` contains a ConditionalBlock |
| 76 | +3. ConditionalBlock's filter runs and returns `el.content` (the wrongly-typed List) |
| 77 | +4. Later code tries to walk this returned content |
| 78 | +5. CRASH: `pandoc.List` doesn't have the expected methods |
| 79 | + |
| 80 | +**But ConditionalBlock is just the messenger!** The bug was already there when `pandoc.List()` was assigned to `.content`. ConditionalBlock just exposed it by propagating the wrong type upward. |
| 81 | + |
| 82 | +## Other Potential Triggers (Without ConditionalBlock) |
| 83 | + |
| 84 | +The bug could be triggered by: |
| 85 | + |
| 86 | +### 1. Direct AST Walking |
| 87 | +```lua |
| 88 | +local blocks = pandoc.List() |
| 89 | +divEl.content = blocks |
| 90 | +_quarto.ast.walk(divEl, {...}) -- CRASH: even without ConditionalBlock |
| 91 | +``` |
| 92 | + |
| 93 | +### 2. Custom Filters That Return .content |
| 94 | +Any custom filter extension that does: |
| 95 | +```lua |
| 96 | +function MyFilter(el) |
| 97 | + return el.content -- If content is wrongly-typed List |
| 98 | +end |
| 99 | +``` |
| 100 | + |
| 101 | +### 3. Filter Chain Interactions |
| 102 | +When filters compose and one expects `pandoc.Blocks` but receives `pandoc.List`. |
| 103 | + |
| 104 | +### 4. Format-Specific Processing |
| 105 | +Some output formats may do additional AST traversal that would trigger the crash. |
| 106 | + |
| 107 | +## Analysis of Each Occurrence |
| 108 | + |
| 109 | +### manuscript.lua (Line 68) - **BUGGY** ✗ |
| 110 | + |
| 111 | +**Code**: |
| 112 | +```lua |
| 113 | +local blocks = pandoc.List() |
| 114 | +-- ... build blocks ... |
| 115 | +divEl.content = blocks -- Assigns List to .content |
| 116 | +``` |
| 117 | + |
| 118 | +**Why it's buggy**: |
| 119 | +- Assigns `pandoc.List()` to `divEl.content` |
| 120 | +- Later code walks the AST (line 138: `_quarto.ast.walk(divEl, ...)`) |
| 121 | +- Exposed by ConditionalBlock, but would crash on any AST walk |
| 122 | + |
| 123 | +**Fix applied**: Changed to `pandoc.Blocks({})` |
| 124 | + |
| 125 | +### outputs.lua (Line 41) - **SAFE** ✓ |
| 126 | + |
| 127 | +**Code**: |
| 128 | +```lua |
| 129 | +local blocks = pandoc.List() |
| 130 | +-- ... build blocks ... |
| 131 | +return blocks -- Returns directly from filter |
| 132 | +``` |
| 133 | + |
| 134 | +**Why it's safe**: |
| 135 | +- Returns `pandoc.List()` directly from the filter |
| 136 | +- Does NOT assign to `.content` |
| 137 | +- Pandoc's filter system can handle returned Lists |
| 138 | +- The returned blocks are used to replace content, not stored as-is |
| 139 | + |
| 140 | +**No fix needed**: Return pattern is safe. |
| 141 | + |
| 142 | +### jats.lua quarto-post (Line 57) - **SAFE** ✓ |
| 143 | + |
| 144 | +**Code**: |
| 145 | +```lua |
| 146 | +function unrollDiv(div, fnSkip) |
| 147 | + local blocks = pandoc.List() |
| 148 | + -- ... build blocks ... |
| 149 | + return blocks -- Returns directly |
| 150 | +end |
| 151 | +``` |
| 152 | + |
| 153 | +**Why it's safe**: Same as outputs.lua - returns directly, never assigns to `.content`. |
| 154 | + |
| 155 | +**No fix needed**: Return pattern is safe. |
| 156 | + |
| 157 | +### jats.lua quarto-post (Line 175) - **BUGGY** ✗ |
| 158 | + |
| 159 | +**Code**: |
| 160 | +```lua |
| 161 | +local orderedContents = pandoc.List() |
| 162 | +orderedContents:extend(otherEls) |
| 163 | +orderedContents:extend(outputEls) |
| 164 | +div.content = orderedContents -- Assigns List to .content |
| 165 | +``` |
| 166 | + |
| 167 | +**Why it's buggy**: |
| 168 | +- Assigns `pandoc.List()` to `div.content` |
| 169 | +- Same pattern as manuscript.lua |
| 170 | +- Could crash if this div is later walked or returned by a filter like ConditionalBlock |
| 171 | + |
| 172 | +**Needs fix**: Change line 172 to `pandoc.Blocks({})` |
| 173 | + |
| 174 | +### dashboard.lua (Lines 248, 296) - **SAFE** ✓ |
| 175 | + |
| 176 | +**Code**: |
| 177 | +```lua |
| 178 | +local results = pandoc.List() |
| 179 | +-- ... build results ... |
| 180 | +return pandoc.Blocks(results) -- Wraps in pandoc.Blocks before return |
| 181 | +``` |
| 182 | + |
| 183 | +**Why it's safe**: |
| 184 | +- Uses `pandoc.List()` internally (fine for building) |
| 185 | +- Wraps in `pandoc.Blocks()` before returning |
| 186 | +- Returns the correct type |
| 187 | + |
| 188 | +**No fix needed**: Already handles type correctly. |
| 189 | + |
| 190 | +## The Fix Pattern |
| 191 | + |
| 192 | +### When to Use pandoc.Blocks() |
| 193 | + |
| 194 | +Use `pandoc.Blocks({})` when: |
| 195 | +1. Building a collection that will be assigned to `.content` |
| 196 | +2. Building a collection that will be part of the document AST |
| 197 | +3. Building blocks that need to support `.walk()` operations |
| 198 | + |
| 199 | +```lua |
| 200 | +-- CORRECT |
| 201 | +local blocks = pandoc.Blocks({}) |
| 202 | +blocks:extend(childContent) |
| 203 | +element.content = blocks |
| 204 | +``` |
| 205 | + |
| 206 | +### When pandoc.List() is OK |
| 207 | + |
| 208 | +Use `pandoc.List()` when: |
| 209 | +1. Building a temporary collection that will be returned directly (filter system handles it) |
| 210 | +2. Building a collection that will be wrapped in `pandoc.Blocks()` before use |
| 211 | +3. Building non-Block collections (like List of Inlines, table rows, etc.) |
| 212 | + |
| 213 | +```lua |
| 214 | +-- CORRECT: Direct return |
| 215 | +local blocks = pandoc.List() |
| 216 | +blocks:insert(something) |
| 217 | +return blocks -- Filter system handles conversion |
| 218 | + |
| 219 | +-- CORRECT: Explicit wrap |
| 220 | +local blocks = pandoc.List() |
| 221 | +blocks:insert(something) |
| 222 | +return pandoc.Blocks(blocks) |
| 223 | + |
| 224 | +-- CORRECT: Non-block collection |
| 225 | +local inlines = pandoc.List() -- Building Inlines, not Blocks |
| 226 | +``` |
| 227 | + |
| 228 | +## Root Cause Summary |
| 229 | + |
| 230 | +The bug is fundamentally about **type safety in Lua's Pandoc AST**: |
| 231 | + |
| 232 | +1. `.content` properties expect `pandoc.Blocks` (which has `.walk()`) |
| 233 | +2. Using `pandoc.List()` creates wrong type without required methods |
| 234 | +3. Crash occurs when AST traversal tries to use `.walk()` on the List |
| 235 | +4. ConditionalBlock exposed the bug by returning wrongly-typed content |
| 236 | +5. But ANY AST walking or content propagation could trigger it |
| 237 | + |
| 238 | +## Recommended Actions |
| 239 | + |
| 240 | +1. **Fix jats.lua line 172**: Change `pandoc.List()` to `pandoc.Blocks({})` |
| 241 | +2. **Test verification**: Create tests for both manuscript and JATS formats with ConditionalBlock |
| 242 | +3. **Pattern search**: Search codebase for `.content = .*pandoc.List` pattern |
| 243 | +4. **Documentation**: Consider adding a code comment in blocks.lua about when to use which type |
| 244 | + |
| 245 | +## Testing Strategy |
| 246 | + |
| 247 | +Tests should verify: |
| 248 | +1. Documents with ConditionalBlock in manuscript format (already exists) |
| 249 | +2. Documents with ConditionalBlock in JATS format (needs creation) |
| 250 | +3. Direct AST walking on elements with `.content` assigned (stress test) |
| 251 | +4. Filter composition scenarios |
| 252 | + |
| 253 | +The tests should confirm that: |
| 254 | +- Fixed code works with ConditionalBlock |
| 255 | +- Fixed code works with direct AST walking |
| 256 | +- Safe patterns (outputs.lua, dashboard.lua) continue working |
0 commit comments