use crate::blocks::{Block, BlockWithContext};
use crate::fs::FileSystem;
use crate::repo_path::RepoPath;
use crate::validators::parse_block_references;
use crate::validators::{
BlockReference, PatternContent, ValidationContext, ValidationReport, ValidatorAsync,
ValidatorDetector, ValidatorType, Violation, ViolationRange, block_content_for_pattern,
};
use anyhow::{Context, anyhow};
use async_trait::async_trait;
use mlua::{HookTriggers, Lua, StdLib, VmState};
use serde::Serialize;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::task::{JoinSet, spawn_blocking};
const LUA_STDLIB_ENV_VAR: &str = "BLOCKWATCH_LUA_MODE";
const DEFAULT_CHECK_LUA_TIMEOUT_SECS: u64 = 30;
const CHECK_LUA_INSTRUCTION_HOOK_INTERVAL: u32 = 1_000;
const CHECK_LUA_TIMEOUT_BACKSTOP_GRACE: Duration = Duration::from_secs(1);
fn lua_from_env() -> Lua {
match std::env::var(LUA_STDLIB_ENV_VAR)
.as_deref()
.unwrap_or("sandboxed")
{
"unsafe" => unsafe { Lua::unsafe_new() },
"safe" => Lua::new(),
_ => Lua::new_with(
StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH,
Default::default(),
)
.expect("failed to start Lua"),
}
}
pub(crate) struct CheckLuaValidator<Fs: FileSystem> {
file_system: Arc<Fs>,
}
impl<Fs: FileSystem + 'static> CheckLuaValidator<Fs> {
pub(super) fn new(file_system: Arc<Fs>) -> Self {
Self { file_system }
}
}
#[async_trait]
impl<Fs: FileSystem + 'static> ValidatorAsync for CheckLuaValidator<Fs> {
async fn validate(&self, context: Arc<ValidationContext>) -> anyhow::Result<ValidationReport> {
let mut report = ValidationReport::default();
let mut tasks = JoinSet::new();
for (file_path, file_blocks) in &context.blocks {
for (block_idx, block_with_context) in
file_blocks.blocks_with_context.iter().enumerate()
{
if let Some(script_path) = block_with_context.block.attributes.get("check-lua") {
if script_path.trim().is_empty() {
return Err(anyhow!(
"check-lua requires a non-empty script path in {}:{} at line {}",
file_path.display(),
block_with_context.block.name_display(),
block_with_context
.block
.start_tag_position_range
.start()
.line
));
};
} else {
continue;
}
report.add_checked_block(file_path, &block_with_context.block);
let context = Arc::clone(&context);
let file_path = file_path.clone();
let file_system = Arc::clone(&self.file_system);
tasks.spawn(async move {
let file_blocks = &context.blocks[&file_path];
let block_with_context = &file_blocks.blocks_with_context[block_idx];
let script_path = &block_with_context.block.attributes["check-lua"];
let content = match block_content_for_pattern(
block_with_context,
&file_blocks.file_content,
"check-lua-pattern",
)? {
PatternContent::Whole(content) => LuaContent::Text(content.to_string()),
PatternContent::Matches(matches) => {
LuaContent::Matches(matches.into_iter().map(str::to_string).collect())
}
};
let affected_blocks = resolve_affected_blocks(
&context,
file_system.as_ref(),
&file_path,
&block_with_context.block,
)?;
let result = run_lua_script(
script_path,
file_system.as_ref(),
&file_path,
block_with_context,
content,
&affected_blocks,
)
.await;
let block_violations = match result.context(format!(
"check-lua script error in {}:{} at line {}",
file_path.display(),
block_with_context.block.name_display(),
block_with_context
.block
.start_tag_position_range
.start()
.line
))? {
None => Vec::new(),
Some(msg) => vec![create_violation(
&file_path,
&block_with_context.block,
script_path,
&msg,
)?],
};
anyhow::Ok((file_path, block_violations))
});
}
}
while let Some(task_result) = tasks.join_next().await {
let (file_path, violations) = task_result.context("check-lua task failed")??;
report.add_violations(&file_path, violations);
}
Ok(report)
}
}
async fn run_lua_script<Fs: FileSystem>(
script_path: &str,
file_system: &Fs,
file_path: &RepoPath,
block_with_context: &BlockWithContext,
content: LuaContent,
affected_blocks: &[AffectedBlock],
) -> anyhow::Result<Option<String>> {
let timeout = parse_check_lua_timeout(&block_with_context.block)?;
let script_content = file_system
.read_to_string(Path::new(script_path))
.with_context(|| format!("failed to read Lua script: {script_path}"))?;
let inputs = LuaScriptInputs::new(
script_path,
script_content,
timeout,
file_path,
block_with_context,
content,
affected_blocks,
);
let worker = spawn_blocking(move || run_lua_script_sync(inputs));
match tokio::time::timeout(timeout + CHECK_LUA_TIMEOUT_BACKSTOP_GRACE, worker).await {
Ok(worker_result) => worker_result.context("the check-lua worker thread panicked")?,
Err(_elapsed) => Err(anyhow!(timeout_error_message(timeout))),
}
}
struct LuaScriptInputs {
script_path: String,
script_content: String,
timeout: Duration,
file: String,
line: usize,
attributes: Vec<(String, String)>,
affects: Option<Vec<LuaAffected>>,
content: LuaContent,
}
enum LuaContent {
Text(String),
Matches(Vec<String>),
}
struct LuaAffected {
file: String,
name: Option<String>,
content: String,
}
impl LuaScriptInputs {
fn new(
script_path: &str,
script_content: String,
timeout: Duration,
file_path: &RepoPath,
block_with_context: &BlockWithContext,
content: LuaContent,
affected_blocks: &[AffectedBlock],
) -> Self {
let block = &block_with_context.block;
let affects = block.attributes.contains_key("affects").then(|| {
affected_blocks
.iter()
.map(|affected| LuaAffected {
file: affected.file.as_str().to_string(),
name: affected.name.clone(),
content: affected.content.clone(),
})
.collect()
});
Self {
script_path: script_path.to_string(),
script_content,
timeout,
file: file_path.as_str().to_string(),
line: block.start_tag_position_range.start().line,
attributes: block
.attributes
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
affects,
content,
}
}
}
fn run_lua_script_sync(inputs: LuaScriptInputs) -> anyhow::Result<Option<String>> {
let lua = lua_from_env();
install_timeout_hook(&lua, inputs.timeout)?;
lua.load(lua_chunk(&inputs.script_content))
.exec()
.with_context(|| format!("failed to execute Lua script: {}", inputs.script_path))?;
let validate_fn: mlua::Function = lua
.globals()
.get("validate")
.context("Lua script must define a global 'validate' function")?;
let ctx_table = lua.create_table().context("failed to create ctx table")?;
ctx_table
.set("file", inputs.file.as_str())
.context("failed to set ctx.file")?;
ctx_table
.set("line", inputs.line)
.context("failed to set ctx.line")?;
let attrs_table = lua.create_table().context("failed to create attrs table")?;
for (key, value) in &inputs.attributes {
attrs_table
.set(key.as_str(), value.as_str())
.with_context(|| format!("failed to set attr {key}"))?;
}
ctx_table
.set("attrs", attrs_table)
.context("failed to set ctx.attrs")?;
if let Some(affected_blocks) = &inputs.affects {
let affects_table = lua
.create_table()
.context("failed to create affects table")?;
for (i, affected) in affected_blocks.iter().enumerate() {
let entry = lua
.create_table()
.context("failed to create affects entry table")?;
entry
.set("file", affected.file.as_str())
.context("failed to set ctx.affects[].file")?;
if let Some(name) = &affected.name {
entry
.set("name", name.as_str())
.context("failed to set ctx.affects[].name")?;
}
entry
.set("content", affected.content.as_str())
.context("failed to set ctx.affects[].content")?;
affects_table
.set(i + 1, entry)
.context("failed to set ctx.affects entry")?;
}
ctx_table
.set("affects", affects_table)
.context("failed to set ctx.affects")?;
}
let content: mlua::Value = match &inputs.content {
LuaContent::Text(text) => mlua::Value::String(
lua.create_string(text.as_str())
.context("failed to build the content string")?,
),
LuaContent::Matches(matches) => mlua::Value::Table(
lua.create_sequence_from(matches.iter().map(String::as_str))
.context("failed to build the content array")?,
),
};
let result: mlua::Value = validate_fn
.call((ctx_table, content))
.with_context(|| format!("failed to call validate() in {}", inputs.script_path))?;
match result {
mlua::Value::Nil => Ok(None),
mlua::Value::String(s) => Ok(Some(s.to_str()?.to_string())),
other => Err(anyhow!(
"validate() must return nil or a string, got: {:?}",
other.type_name()
)),
}
}
fn parse_check_lua_timeout(block: &Block) -> anyhow::Result<Duration> {
let Some(raw) = block.attributes.get("check-lua-timeout") else {
return Ok(Duration::from_secs(DEFAULT_CHECK_LUA_TIMEOUT_SECS));
};
let seconds: u64 = raw
.trim()
.parse()
.ok()
.filter(|&seconds| seconds >= 1)
.ok_or_else(|| {
anyhow!("check-lua-timeout must be a whole number of seconds >= 1, got {raw:?}")
})?;
Ok(Duration::from_secs(seconds))
}
fn timeout_error_message(timeout: Duration) -> String {
let seconds = timeout.as_secs();
format!(
"check-lua script timed out after {seconds} second{}",
if seconds == 1 { "" } else { "s" }
)
}
fn install_timeout_hook(lua: &Lua, timeout: Duration) -> anyhow::Result<()> {
let deadline = Instant::now() + timeout;
lua.set_global_hook(
HookTriggers::new().every_nth_instruction(CHECK_LUA_INSTRUCTION_HOOK_INTERVAL),
move |_lua, _debug| {
if Instant::now() >= deadline {
Err(mlua::Error::runtime(timeout_error_message(timeout)))
} else {
Ok(VmState::Continue)
}
},
)
.context("failed to install the check-lua timeout hook")
}
fn lua_chunk(script: &str) -> &str {
let script = script.strip_prefix('\u{feff}').unwrap_or(script);
if !script.starts_with('#') {
return script;
}
match script.find('\n') {
Some(line_end) => &script[line_end..],
None => "",
}
}
fn create_violation(
file_path: &RepoPath,
block: &Block,
script_path: &str,
error_message: &str,
) -> anyhow::Result<Violation> {
let details = serde_json::to_value(CheckLuaViolation {
script: script_path,
lua_error: error_message,
})
.context("failed to serialize CheckLuaDetails")?;
let message = format!(
"Block {}:{} defined at line {} failed Lua check: {error_message}",
file_path.display(),
block.name_display(),
block.start_tag_position_range.start().line,
);
Violation::new(
ViolationRange::new(
block.start_tag_position_range.start().clone(),
block.start_tag_position_range.end().clone(),
),
file_path,
block,
"check-lua".to_string(),
message,
None,
Some(details),
)
}
struct AffectedBlock {
file: RepoPath,
name: Option<String>,
content: String,
}
fn resolve_affected_blocks<Fs: FileSystem>(
context: &ValidationContext,
file_system: &Fs,
current_file_path: &RepoPath,
block: &Block,
) -> anyhow::Result<Vec<AffectedBlock>> {
let mut result = Vec::new();
let Some(affects) = block.attributes.get("affects") else {
return Ok(result);
};
let references = parse_block_references(affects).with_context(|| {
format!(
"invalid affects reference on block {}:{} at line {}",
current_file_path,
block.name_display(),
block.start_tag_position_range.start().line,
)
})?;
for reference in references {
match reference {
BlockReference::Block { file, name } => {
let file = file.unwrap_or_else(|| current_file_path.clone());
let Some(file_blocks) = context.blocks.get(&file) else {
continue;
};
for block_with_context in &file_blocks.blocks_with_context {
if block_with_context.block.name() == Some(name.as_str()) {
result.push(AffectedBlock {
file: file.clone(),
name: Some(name.clone()),
content: block_with_context
.block
.content(&file_blocks.file_content)
.trim()
.to_string(),
});
}
}
}
BlockReference::File(file) => {
let content = match context.blocks.get(&file) {
Some(file_blocks) => file_blocks.file_content.clone(),
None => match file_system.read_to_string(file.as_path()) {
Ok(content) => content,
Err(_) => continue,
},
};
result.push(AffectedBlock {
file,
name: None,
content: content.trim().to_string(),
});
}
}
}
Ok(result)
}
pub(crate) struct CheckLuaValidatorDetector;
impl CheckLuaValidatorDetector {
pub fn new() -> Self {
Self
}
}
impl<Fs: FileSystem + 'static> ValidatorDetector<Fs> for CheckLuaValidatorDetector {
fn detect(
&self,
block_with_context: &BlockWithContext,
file_system: &Arc<Fs>,
) -> anyhow::Result<Option<ValidatorType>> {
if block_with_context
.block
.attributes
.contains_key("check-lua")
{
Ok(Some(ValidatorType::Async(Box::new(
CheckLuaValidator::new(Arc::clone(file_system)),
))))
} else {
Ok(None)
}
}
}
#[derive(Serialize)]
struct CheckLuaViolation<'a> {
script: &'a str,
lua_error: &'a str,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::test_utils::FakeFileSystem;
use crate::repo_path::RepoPath;
use crate::test_utils::{
checked_lines, merge_validation_contexts, validation_context,
validation_context_with_changes, violation_count,
};
use serde_json::json;
fn validator(scripts: &[(&str, &str)]) -> CheckLuaValidator<FakeFileSystem> {
let files = scripts
.iter()
.map(|(path, contents)| (path.to_string(), contents.to_string()))
.collect();
CheckLuaValidator::new(Arc::new(FakeFileSystem::new(files)))
}
#[tokio::test]
async fn block_whose_script_fails_returns_a_violation() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua">
some content
# </block>"#,
);
let violations = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
return "block content is invalid"
end
"#,
)])
.validate(context)
.await?
.violations;
assert_eq!(violations.len(), 1);
assert_eq!(
violations[&RepoPath::from_reference("example.py")?].len(),
1
);
let violation = &violations[&RepoPath::from_reference("example.py")?][0];
assert_eq!(violation.code, "check-lua");
assert_eq!(
violation.message,
"Block example.py:(unnamed) defined at line 1 failed Lua check: block content is invalid"
);
assert_eq!(
violation.data,
Some(json!({
"script": "check.lua",
"lua_error": "block content is invalid"
}))
);
Ok(())
}
#[tokio::test]
async fn block_whose_script_passes_returns_no_violations() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua">
some content
# </block>"#,
);
let report = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
return nil
end
"#,
)])
.validate(context)
.await?;
assert!(report.violations.is_empty());
assert_eq!(checked_lines(&report), vec![1]);
Ok(())
}
#[tokio::test]
async fn block_without_a_check_lua_pattern_passes_the_content_as_a_string() -> anyhow::Result<()>
{
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua">
name: Alice, id: 42
# </block>"#,
);
let violations = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
if content ~= "name: Alice, id: 42" then
return "expected the whole block as a string, got " .. type(content)
end
return nil
end
"#,
)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn check_lua_pattern_without_a_value_group_passes_the_whole_match() -> anyhow::Result<()>
{
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" check-lua-pattern="id: \d+">
name: Alice, id: 42
# </block>"#,
);
let violations = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
if content[1] ~= "id: 42" then
return "expected 'id: 42' but got '" .. tostring(content[1]) .. "'"
end
return nil
end
"#,
)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn check_lua_pattern_with_a_value_group_passes_the_captured_value() -> anyhow::Result<()>
{
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" check-lua-pattern="id: (?P<value>\d+)">
name: Alice, id: 42
# </block>"#,
);
let violations = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
if content[1] ~= "42" then
return "expected '42' but got '" .. tostring(content[1]) .. "'"
end
return nil
end
"#,
)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn check_lua_pattern_with_several_matches_passes_all_of_them() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" check-lua-pattern="id: (?P<value>\d+)">
name: Alice, id: 42
name: Bob, id: 7
# </block>"#,
);
let violations = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
if #content ~= 2 then
return "expected 2 matches, got " .. #content
end
if content[1] ~= "42" or content[2] ~= "7" then
return "unexpected matches: " .. table.concat(content, ",")
end
return nil
end
"#,
)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn check_lua_pattern_matching_nothing_passes_an_empty_array() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" check-lua-pattern="zzz_no_match">
name: Alice, id: 42
# </block>"#,
);
let violations = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
if type(content) ~= "table" then
return "expected a table, got " .. type(content)
end
if #content ~= 0 then
return "expected no matches, got " .. #content
end
return nil
end
"#,
)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn check_lua_pattern_matching_empty_values_skips_them() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" check-lua-pattern="\d*">
a1
b22
# </block>"#,
);
let violations = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
if #content ~= 2 then
return "expected 2 matches, got " .. #content
end
if content[1] ~= "1" or content[2] ~= "22" then
return "unexpected matches: " .. table.concat(content, ",")
end
return nil
end
"#,
)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty(), "{violations:?}");
Ok(())
}
#[tokio::test]
async fn check_lua_pattern_spanning_several_lines_matches_across_them() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" check-lua-pattern="(?s)BEGIN(?P<value>.*?)END">
BEGIN
middle
END
# </block>"#,
);
let violations = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
if content[1] ~= "\nmiddle\n" then
return "expected '\nmiddle\n' but got '" .. tostring(content[1]) .. "'"
end
return nil
end
"#,
)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn invalid_check_lua_pattern_returns_an_error() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" check-lua-pattern="[invalid">
some content
# </block>"#,
);
let err = validator(&[]).validate(context).await.unwrap_err();
let err_chain = format!("{err:#}");
assert!(
err_chain.contains("check-lua-pattern is not a valid regex"),
"unexpected error: {err_chain}"
);
Ok(())
}
#[tokio::test]
async fn lua_context_exposes_the_block_fields() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua">
some content
# </block>"#,
);
let violations = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
if ctx.file ~= "example.py" then
return "ctx.file is not 'example.py'"
end
if ctx.line ~= 1 then
return "ctx.line is not 1"
end
if ctx.attrs == nil then
return "ctx.attrs is nil"
end
if ctx.attrs["check-lua"] == nil then
return "ctx.attrs['check-lua'] is nil"
end
return nil
end
"#,
)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn lua_context_exposes_the_affected_blocks() -> anyhow::Result<()> {
let script = r#"
function validate(ctx, content)
if ctx.affects == nil then
return "ctx.affects is nil"
end
if #ctx.affects ~= 2 then
return "expected 2 affected blocks, got " .. tostring(#ctx.affects)
end
if ctx.affects[1].file ~= "example.py" then
return "ctx.affects[1].file is '" .. tostring(ctx.affects[1].file) .. "'"
end
if ctx.affects[1].name ~= "local-block" then
return "ctx.affects[1].name is '" .. tostring(ctx.affects[1].name) .. "'"
end
if ctx.affects[1].content ~= "local content" then
return "ctx.affects[1].content is '" .. tostring(ctx.affects[1].content) .. "'"
end
if ctx.affects[2].file ~= "other.py" then
return "ctx.affects[2].file is '" .. tostring(ctx.affects[2].file) .. "'"
end
if ctx.affects[2].name ~= "remote-block" then
return "ctx.affects[2].name is '" .. tostring(ctx.affects[2].name) .. "'"
end
if ctx.affects[2].content ~= "remote content" then
return "ctx.affects[2].content is '" .. tostring(ctx.affects[2].content) .. "'"
end
return nil
end
"#;
let context = merge_validation_contexts(vec![
validation_context(
"example.py",
r#"# <block check-lua="check.lua" affects=":local-block, other.py:remote-block">
some content
# </block>
# <block name="local-block">
local content
# </block>"#,
),
validation_context(
"other.py",
r#"# <block name="remote-block">
remote content
# </block>"#,
),
]);
let violations = validator(&[("check.lua", script)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn lua_context_exposes_a_whole_file_affects_target() -> anyhow::Result<()> {
let script = r#"
function validate(ctx, content)
if #ctx.affects ~= 1 then
return "expected 1 affected target, got " .. tostring(#ctx.affects)
end
if ctx.affects[1].file ~= "config.json" then
return "ctx.affects[1].file is '" .. tostring(ctx.affects[1].file) .. "'"
end
if ctx.affects[1].name ~= nil then
return "ctx.affects[1].name is '" .. tostring(ctx.affects[1].name) .. "'"
end
if ctx.affects[1].content ~= '{"value": 2}' then
return "ctx.affects[1].content is '" .. tostring(ctx.affects[1].content) .. "'"
end
return nil
end
"#;
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" affects="config.json">
some content
# </block>"#,
);
let violations = validator(&[("check.lua", script), ("config.json", "{\"value\": 2}\n")])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn lua_context_skips_unresolved_affects_references() -> anyhow::Result<()> {
let script = r#"
function validate(ctx, content)
if ctx.affects == nil then
return "ctx.affects is nil"
end
if #ctx.affects ~= 0 then
return "expected 0 affected blocks, got " .. tostring(#ctx.affects)
end
return nil
end
"#;
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" affects=":does-not-exist">
some content
# </block>"#,
);
let violations = validator(&[("check.lua", script)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn lua_context_skips_an_unreadable_whole_file_affects_target() -> anyhow::Result<()> {
let script = r#"
function validate(ctx, content)
if #ctx.affects ~= 0 then
return "expected 0 affected targets, got " .. tostring(#ctx.affects)
end
return nil
end
"#;
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" affects="gone.json">
some content
# </block>"#,
);
let violations = validator(&[("check.lua", script)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn lua_context_excludes_affected_blocks_absent_from_the_diff() -> anyhow::Result<()> {
let script = r#"
function validate(ctx, content)
if ctx.affects == nil then
return "ctx.affects is nil"
end
if #ctx.affects ~= 0 then
return "expected 0 affected blocks, got " .. tostring(#ctx.affects)
end
return nil
end
"#;
let context = merge_validation_contexts(vec![
validation_context(
"example.py",
r#"# <block check-lua="check.lua" affects="other.py:remote-block">
some content
# </block>"#,
),
validation_context_with_changes(
"other.py",
r#"# <block name="remote-block">
remote content
# </block>"#,
vec![], ),
]);
let violations = validator(&[("check.lua", script)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn block_without_an_affects_attribute_has_no_lua_context_affects() -> anyhow::Result<()> {
let script = r#"
function validate(ctx, content)
if ctx.affects ~= nil then
return "ctx.affects should be nil"
end
return nil
end
"#;
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua">
some content
# </block>"#,
);
let violations = validator(&[("check.lua", script)])
.validate(context)
.await?
.violations;
assert!(violations.is_empty());
Ok(())
}
#[tokio::test]
async fn blocks_with_and_without_check_lua_record_a_check_only_for_the_examined_ones()
-> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block name="passing" check-lua="ok.lua">
some content
# </block>
# <block name="failing" check-lua="fail.lua">
some content
# </block>
# <block name="unrelated">
some content
# </block>"#,
);
let report = validator(&[
(
"ok.lua",
r#"
function validate(ctx, content)
return nil
end
"#,
),
(
"fail.lua",
r#"
function validate(ctx, content)
return "bad content"
end
"#,
),
])
.validate(context)
.await?;
assert_eq!(checked_lines(&report), vec![1, 4]);
assert_eq!(violation_count(&report), 1);
Ok(())
}
#[tokio::test]
async fn block_exceeding_its_check_lua_timeout_fails_the_run() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="loop.lua" check-lua-timeout="1">
some content
# </block>"#,
);
let err = validator(&[(
"loop.lua",
r#"
function validate(ctx, content)
while true do end
end
"#,
)])
.validate(context)
.await
.unwrap_err();
let err_chain = format!("{err:#}");
assert!(
err_chain.contains("timed out") && err_chain.contains("1 second"),
"unexpected error: {err_chain}"
);
Ok(())
}
#[tokio::test]
async fn block_within_its_check_lua_timeout_passes() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" check-lua-timeout="5">
some content
# </block>"#,
);
let report = validator(&[(
"check.lua",
r#"
function validate(ctx, content)
return nil
end
"#,
)])
.validate(context)
.await?;
assert!(report.violations.is_empty());
assert_eq!(checked_lines(&report), vec![1]);
Ok(())
}
#[tokio::test]
async fn non_numeric_check_lua_timeout_returns_an_error() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" check-lua-timeout="soon">
some content
# </block>"#,
);
let err = validator(&[]).validate(context).await.unwrap_err();
let err_chain = format!("{err:#}");
assert!(
err_chain.contains("check-lua-timeout must be a whole number of seconds"),
"unexpected error: {err_chain}"
);
Ok(())
}
#[tokio::test]
async fn zero_check_lua_timeout_returns_an_error() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua" check-lua-timeout="0">
some content
# </block>"#,
);
let err = validator(&[]).validate(context).await.unwrap_err();
let err_chain = format!("{err:#}");
assert!(
err_chain.contains("check-lua-timeout must be a whole number of seconds"),
"unexpected error: {err_chain}"
);
Ok(())
}
#[tokio::test]
async fn script_with_a_shebang_line_runs() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua">
some content
# </block>"#,
);
let report = validator(&[(
"check.lua",
r#"#!/usr/bin/env lua
function validate(ctx, content)
return nil
end
"#,
)])
.validate(context)
.await?;
assert!(report.violations.is_empty());
assert_eq!(checked_lines(&report), vec![1]);
Ok(())
}
#[tokio::test]
async fn script_with_a_utf8_bom_runs() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua">
some content
# </block>"#,
);
let report = validator(&[(
"check.lua",
"\u{feff}function validate(ctx, content)\n return nil\nend\n",
)])
.validate(context)
.await?;
assert!(report.violations.is_empty());
assert_eq!(checked_lines(&report), vec![1]);
Ok(())
}
#[tokio::test]
async fn script_with_a_utf8_bom_before_a_shebang_line_runs() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua">
some content
# </block>"#,
);
let report = validator(&[(
"check.lua",
"\u{feff}#!/usr/bin/env lua\nfunction validate(ctx, content)\n return nil\nend\n",
)])
.validate(context)
.await?;
assert!(report.violations.is_empty());
assert_eq!(checked_lines(&report), vec![1]);
Ok(())
}
#[tokio::test]
async fn script_with_a_shebang_line_reports_lua_errors_at_their_original_line()
-> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="check.lua">
some content
# </block>"#,
);
let err = validator(&[(
"check.lua",
r#"#!/usr/bin/env lua
function validate(ctx, content)
this is not lua
end
"#,
)])
.validate(context)
.await
.unwrap_err();
let err_chain = format!("{err:#}");
assert!(
err_chain.contains(":3:"),
"expected the error to name line 3: {err_chain}"
);
Ok(())
}
#[tokio::test]
async fn missing_script_returns_an_error() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua="missing.lua">
text
# </block>"#,
);
let err = validator(&[]).validate(context).await.unwrap_err();
let err_chain = format!("{err:#}");
assert!(
err_chain.contains("failed to read Lua script"),
"unexpected error: {err_chain}"
);
Ok(())
}
#[tokio::test]
async fn empty_script_path_returns_an_error() -> anyhow::Result<()> {
let context = validation_context(
"example.py",
r#"# <block check-lua=" ">
text
# </block>"#,
);
let err = validator(&[]).validate(context).await.unwrap_err();
assert!(
err.to_string()
.contains("check-lua requires a non-empty script path")
);
Ok(())
}
}