Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- feat: Add `Checker:cells`, which checks the comment-pipe options of one code cell against the `cells` section and returns what they resolve to. It needs Quarto Wizard schema 2.3.0 or newer, whose validator provides `validate_cells`. (#16)

## 2.5.1 (2026-10-18)

- fix: Load sibling modules on q2, which has no `debug` library. An extension that vendors the modules no longer stops there with a shortcode script error. (#12)
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ local defaults, resolved = checker:options(meta)
local inline = checker:option('inline')
local attributes = checker:attributes(el.attributes, 'CodeBlock')
local format = checker:format('letter-pdf')
local cell = checker:cells(cell_options, 'typst')
checker:call('iconify', args, kwargs)
```

Expand All @@ -50,6 +51,10 @@ Read it rather than the document, so that the schema decides what counts as true
`attributes` checks one element against the `attributes` section and returns what its attributes resolve to.
Both the element's own group and `_any` apply, the named group last.

`cells` checks the comment-pipe options of one code cell (`#|`, `//|`) against the `cells` section and returns what they resolve to.
The cell language picks the group, so a `{.typst}` block that only displays code is not checked as a `typst` cell.
It needs Quarto Wizard schema 2.3.0 or newer, whose validator provides `validate_cells`.

`format` checks one output format against the `formats` section, after `options` has run.
Quarto merges the options of the selected format into the top level of the metadata, so the extension names its own format, as it names an attribute group.

Expand Down
93 changes: 71 additions & 22 deletions modules/schema-check.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
--- vendored copy of this module then knows nothing about where the validator
--- was vendored, so the two sources stay independent. The validator must
--- provide `load_schema`, `validate`, `validate_shortcode`,
--- `extract_meta_options`, `validate_attributes` and `validate_format`.
--- `extract_meta_options`, `validate_attributes`, `validate_format` and
--- `validate_cells`.
---
--- The last two are newer than the rest of that list, so a validator vendored
--- before either exists is reported rather than called. The others are required
--- outright and a validator without one of them raises.
--- The last three are newer than the rest of that list, so a validator
--- vendored before one of them exists is reported rather than called. The
--- others are required outright and a validator without one of them raises.
---
--- `validate_format` must also be the one that reads the top level of the
--- metadata, which is Quarto Wizard 3.6.0 or newer. An older one looks for the
Expand Down Expand Up @@ -73,7 +74,8 @@ local str = load_sibling('string.lua')
--- A finding about an element's attribute is a warning instead. The attribute
--- stays on the element whatever the schema says, so the rendered output does
--- not change because of it, which is the reason a shortcode attribute is a
--- warning too.
--- warning too. A cell option is the same: this module reports it and hands
--- the options back, and the cell renders whatever the finding says.
---
--- `misuse` is the one kind that is not about the document. It reports a fault
--- in the extension calling this module, and it is an error because the caller
Expand All @@ -91,6 +93,8 @@ local SEVERITY = {
attribute_warning = 'warning',
format_error = 'error',
format_warning = 'warning',
cell_error = 'warning',
cell_warning = 'warning',
}

--- The reporting function for each level.
Expand Down Expand Up @@ -216,11 +220,25 @@ function Checker:_report(kind, message)
REPORTERS[level](self.extension, message)
end

--- Report a validator's errors and warnings under one kind's pair of levels.
--- @param kind string The prefix of a `<kind>_error` and `<kind>_warning` pair in `SEVERITY`
--- @param errors string[] The validator's errors
--- @param warnings string[] The validator's warnings
--- @return nil
function Checker:_report_findings(kind, errors, warnings)
for _, message in ipairs(errors) do
self:_report(kind .. '_error', message)
end
for _, message in ipairs(warnings) do
self:_report(kind .. '_warning', message)
end
end

--- Whether the validator provides one function, reporting it once if not.
---
--- `validate_attributes` and `validate_format` are newer than the rest of the
--- contract, so a validator vendored before either exists satisfies everything
--- else and still lacks them. Calling one raises, and a raise removes the
--- `validate_attributes`, `validate_format` and `validate_cells` are newer than
--- the rest of the contract, so a validator vendored before one of them exists
--- satisfies everything else and still lacks them. Calling one raises, and a raise removes the
--- document, which is the one thing this module promises not to do.
---
--- It is reported once for the render rather than once for each caller. A
Expand Down Expand Up @@ -399,17 +417,53 @@ function Checker:attributes(attributes, group)
for _, name in ipairs(groups) do
local _, errors, warnings, merged =
self.validator.validate_attributes(resolved, name, loaded)
for _, message in ipairs(errors) do
self:_report('attribute_error', message)
end
for _, message in ipairs(warnings) do
self:_report('attribute_warning', message)
end
self:_report_findings('attribute', errors, warnings)
resolved = merged
end
return resolved
end

--- Check one code cell's comment-pipe options against the `cells` section, and
--- return what they resolve to.
---
--- The section is keyed by cell language, so an extension that handles `typst`
--- cells checks those cells and nothing else. Checking them under `attributes`
--- would also match a `{.typst}` block that only displays code, which carries
--- the same class and none of the same options.
---
--- The validator matches the language and leaves Quarto's own options, such as
--- `label` and `echo`, alone, because they share the same `#|` and `//|`
--- lines. A language the schema does not declare hands the options back.
---
--- This reports only, as `attributes` does. The cell renders whatever the
--- finding says, so a finding here is a warning.
--- @param options table<string, any> The cell's comment-pipe options
--- @param language string The cell language, as the schema declares it
--- @return table<string, any>|nil resolved The options with the schema applied
function Checker:cells(options, language)
if type(language) ~= 'string' then
self:_report('misuse', string.format(
'schema-check: the language given to `cells` must be a string, got %s', type(language)))
return nil
end

options = options or {}

--- @type table|nil
local loaded = self.schema
if loaded == nil or next(loaded.cells or {}) == nil then
return options
end
if not self:_provides('validate_cells') then
return options
end

local _, errors, warnings, merged =
self.validator.validate_cells(options, language, loaded)
self:_report_findings('cell', errors, warnings)
return merged
end

--- Check one output format's options against the `formats` section, and return
--- what they resolve to.
---
Expand Down Expand Up @@ -451,12 +505,7 @@ function Checker:format(name)

local _, errors, warnings, merged =
self.validator.validate_format(self.meta, name, loaded)
for _, message in ipairs(errors) do
self:_report('format_error', message)
end
for _, message in ipairs(warnings) do
self:_report('format_warning', message)
end
self:_report_findings('format', errors, warnings)

self.formats[name] = merged
return merged
Expand Down Expand Up @@ -545,8 +594,8 @@ end
--- schema is, and the checker it builds belongs at file scope, so that the
--- schema is read once for the render and not once for each call.
--- @param validator table The validator, with `load_schema`, `validate`,
--- `validate_shortcode`, `extract_meta_options`, `validate_attributes` and
--- `validate_format`
--- `validate_shortcode`, `extract_meta_options`, `validate_attributes`,
--- `validate_format` and `validate_cells`
--- @param extension_name string The extension name every message carries
--- @param schema_path string|nil The schema to read, relative to the entry
--- point that is running. Defaults to `_schema.yml`.
Expand Down
113 changes: 113 additions & 0 deletions tests/run.lua
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,20 @@ do
return #(declared.errors or {}) == 0, declared.errors or {}, declared.warnings or {},
declared.merged or {}
end,
-- `spec.languages` names the cell languages the schema declares, each
-- with what it resolves to and what it found. A language absent from it
-- hands the options straight back, which is what the real one does with
-- an undeclared language.
validate_cells = function(options, language, schema)
spec.cell_calls = spec.cell_calls or {}
spec.cell_calls[#spec.cell_calls + 1] = { options = options, language = language, schema = schema }
local declared = (spec.languages or {})[language]
if declared == nil then
return true, {}, {}, options
end
return #(declared.errors or {}) == 0, declared.errors or {}, declared.warnings or {},
declared.merged or {}
end,
}
end

Expand Down Expand Up @@ -1124,6 +1138,105 @@ do
equal(#recorded, 1, 'a validator without `validate_format` is reported once per render')
end

-- Checking one code cell's comment-pipe options. The cell language picks the
-- group, as an element's group picks one under `attributes`, and the options
-- come back as the schema resolves them.
do
install_stubs()
local spec = {
schema = { options = {}, shortcodes = {}, cells = { typst = {} } },
languages = {
typst = {
merged = { dpi = 300, label = 'fig-a' },
errors = { 'typst.dpi: must be of type "integer", got "high".' },
warnings = { 'typst.dpii: is not a recognised key and was ignored.' },
},
},
}
local checker = check_mod.new(stub_validator(spec), 'demo')
local given = { dpi = 'high', dpii = '1', label = 'fig-a' }

local merged = checker:cells(given, 'typst')
equal(merged and merged.dpi, 300, '`cells` returns what the cell options resolved')
equal(#spec.cell_calls, 1, 'the cell is checked once')
check(spec.cell_calls[1].options == given,
'the check reads the options it was given', tostring(spec.cell_calls[1].options))
equal(spec.cell_calls[1].language, 'typst', 'the check is handed the cell language')
check(spec.cell_calls[1].schema == spec.schema,
'the check is handed the schema that was read', tostring(spec.cell_calls[1].schema))
equal(#recorded, 2, 'both the error and the warning are reported')
equal(recorded[1] and recorded[1].level, 'warning',
'a rejected cell option is a warning, as a rejected attribute is')
equal(recorded[2] and recorded[2].level, 'warning', 'an unrecognised cell option is a warning')
equal(recorded[1] and recorded[1].message,
'[demo] typst.dpi: must be of type "integer", got "high".',
'the validator message is reported unchanged, with the extension name')
end

-- Without a schema, or without a `cells` section, there is nothing to check
-- against and the options come back as they went in.
do
install_stubs()
local broken = check_mod.new(
stub_validator({ err = 'Could not open schema file: _schema.yml' }), 'demo')
local given = { dpi = '300' }
check(broken:cells(given, 'typst') == given,
'without a schema `cells` hands back the table it was given', 'a different table')
equal(#recorded, 1, 'without a schema `cells` reports nothing further')

install_stubs()
local spec = { schema = schema_with({}) }
local bare = check_mod.new(stub_validator(spec), 'demo')
local held = { dpi = '300' }
check(bare:cells(held, 'typst') == held,
'with no `cells` section the table is handed back', 'a different table')
equal(spec.cell_calls, nil, 'with no `cells` section the validator is not called')
equal(#recorded, 0, 'with no `cells` section nothing is reported')
end

-- A language that is not a string is the same caller fault `format` guards,
-- and it is reported rather than looked up.
do
install_stubs()
local spec = { schema = { options = {}, shortcodes = {}, cells = { typst = {} } } }
local checker = check_mod.new(stub_validator(spec), 'demo')
equal(checker:cells({ dpi = '300' }, nil), nil, '`cells` without a language answers nothing')
equal(#recorded, 1, 'a missing language is reported once')
equal(recorded[1] and recorded[1].level, 'error', 'a missing language is an error')

install_stubs()
local other = check_mod.new(stub_validator(spec), 'demo')
equal(other:cells({ dpi = '300' }, {}), nil, '`cells` given a table language answers nothing')
equal(#recorded, 1, 'a language that is not a string is reported once')
equal(spec.cell_calls, nil, 'a language that is not a string is not looked up')
end

-- The same allowance `attributes` makes. `validate_cells` is newer than the
-- base contract, so a validator vendored before it exists is reported once
-- for the render rather than called.
do
install_stubs()
local validator = stub_validator({
schema = { options = {}, shortcodes = {}, cells = { typst = {} } },
})
validator.validate_cells = nil
local checker = check_mod.new(validator, 'demo')

local given = { dpi = '300' }
local ok, merged = pcall(checker.cells, checker, given, 'typst')
check(ok, 'a validator without `validate_cells` does not raise',
not ok and tostring(merged) or nil)
check(ok and merged == given, 'the cell options are handed back unchanged',
'a different table')
equal(#recorded, 1, 'a validator without `validate_cells` is reported once')
equal(recorded[1] and recorded[1].level, 'error',
'a validator that cannot check cells is an error')

checker:cells({ dpi = '72' }, 'typst')
checker:cells({ dpi = '96' }, 'python')
equal(#recorded, 1, 'a validator without `validate_cells` is reported once per render')
end

-- An unknown option is advice, not a failure.
do
install_stubs()
Expand Down