Skip to main content

declint_lua/
lib.rs

1//! Lua match callbacks for declint.
2//!
3//! A rule's `callback:` value can be an **inline Lua snippet** (a block
4//! scalar) or a **file path** (`checks/foo.lua`, relative to the config
5//! file). [`attach`] compiles every such reference, registers it in the
6//! callback registry, and from then on the linter calls it per match.
7//!
8//! # Contract
9//!
10//! The snippet is a Lua chunk that **returns** the callback function
11//! (helpers and locals above the `return` just work):
12//!
13//! ```lua
14//! return function(ctx)
15//!   -- ctx.match      the matched text
16//!   -- ctx.captures   { name = "..." } / { ["1"] = "..." }
17//!   -- ctx.start      absolute byte offset
18//!   -- ctx.finish     byte offset past the match
19//!   -- ctx.line, ctx.col   1-based position of the match
20//!   -- ctx.path, ctx.language, ctx.rule
21//! end
22//! ```
23//!
24//! Return values:
25//!
26//! * `nil` / `false` — **allow**: no diagnostic for this match;
27//! * `true` — violate with the rule's own `message` template;
28//! * `{ message = "...", severity = "error" }` — violate with overrides
29//!   (`severity` optional, falls back to the rule's severity);
30//! * a thrown Lua error — surfaced as an `error`-severity diagnostic
31//!   naming the rule; the lint run itself is never affected.
32//!
33//! Each call runs under an instruction budget, so a runaway loop fails
34//! instead of hanging the editor.
35//!
36//! # Examples
37//!
38//! ```
39//! use declint_core::{CallbackRef, Callbacks, ConfigSet};
40//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
41//! # let dir = std::env::temp_dir().join("declint-lua-doc");
42//! # std::fs::create_dir_all(&dir)?;
43//! #     std::fs::write(dir.join(".declint.yaml"),
44//! #     "version: 1\nrules:\n  - id: r\n    pattern: 'x'\n    callback: |\n      return function(c) return { message = \"seen \" .. c.match } end\n")?;
45//! let set = ConfigSet::discover(&dir)?;
46//! let mut callbacks = Callbacks::new();
47//! declint_lua::attach(&set, &mut callbacks)?;
48//! assert!(!callbacks.is_empty());
49//! # std::fs::remove_dir_all(&dir)?;
50//! # Ok(())
51//! # }
52//! ```
53
54#![forbid(unsafe_code)]
55#![deny(missing_docs)]
56
57use std::sync::Arc;
58
59use declint_core::{
60    CallbackRef, Callbacks, ConfigError, ConfigSet, Decision, MatchCallback, MatchContext,
61    MatchParser, RawMatch, Severity,
62};
63use mlua::{Function, Lua, Value};
64
65/// Instructions a callback may execute per call before it is aborted.
66const CALLBACK_INSTRUCTION_BUDGET: u32 = 1_000_000;
67
68/// Instructions a parser may execute per call — larger than the callback
69/// budget, because a parser sees (and scans) the whole text at once.
70/// Prefer `string.find`/`string.gmatch` (they run at C speed) over
71/// per-character Lua loops.
72const PARSER_INSTRUCTION_BUDGET: u32 = 10_000_000;
73
74/// The budget-abort message; used to recognize budget aborts.
75const BUDGET_MESSAGE: &str = "exceeded its instruction budget";
76
77/// A compiled Lua callback.
78pub struct LuaCallback {
79    lua: Arc<Lua>,
80    function: Function,
81}
82
83impl MatchCallback for LuaCallback {
84    fn evaluate(&self, ctx: &MatchContext) -> Result<Decision, String> {
85        let lua = &self.lua;
86
87        let table = lua.create_table().map_err(|e| e.to_string())?;
88        table.set("match", ctx.match_text.as_str()).map_err(|e| e.to_string())?;
89        table.set("start", ctx.start).map_err(|e| e.to_string())?;
90        table.set("finish", ctx.finish).map_err(|e| e.to_string())?;
91        table.set("line", ctx.line).map_err(|e| e.to_string())?;
92        table.set("col", ctx.col).map_err(|e| e.to_string())?;
93        table.set("path", ctx.path.as_str()).map_err(|e| e.to_string())?;
94        table.set("language", ctx.language.as_str()).map_err(|e| e.to_string())?;
95        table.set("rule", ctx.rule_id.as_str()).map_err(|e| e.to_string())?;
96        let captures = lua.create_table().map_err(|e| e.to_string())?;
97        for (name, value) in &ctx.captures {
98            captures.set(name.as_str(), value.as_str()).map_err(|e| e.to_string())?;
99        }
100        table.set("captures", captures).map_err(|e| e.to_string())?;
101
102        lua.set_hook(
103            mlua::HookTriggers::new().every_nth_instruction(CALLBACK_INSTRUCTION_BUDGET),
104            |_lua, _debug| Err(mlua::Error::RuntimeError(BUDGET_MESSAGE.to_string())),
105        )
106        .map_err(|e| e.to_string())?;
107        let result = self.function.call::<Value>(table);
108        lua.remove_hook();
109
110        match result {
111            Ok(Value::Nil) | Ok(Value::Boolean(false)) => Ok(Decision::Allow),
112            Ok(Value::Boolean(true)) => Ok(Decision::ViolateDefault),
113            Ok(Value::Table(outcome)) => {
114                let message: Option<String> = outcome.get("message").ok();
115                let Some(message) = message.filter(|m| !m.is_empty()) else {
116                    return Err("returned a table without a `message` string".into());
117                };
118                let severity = match outcome.get::<Value>("severity") {
119                    Ok(Value::Nil) | Err(_) => None,
120                    Ok(Value::String(s)) => {
121                        let name = s.to_str().map_err(|e| e.to_string())?;
122                        Some(Severity::parse(&name).ok_or_else(|| {
123                            format!("unknown severity `{name}` (expected error, warning, info, hint)")
124                        })?)
125                    }
126                    Ok(other) => {
127                        return Err(format!(
128                            "`severity` must be a string (found {})",
129                            other.type_name()
130                        ))
131                    }
132                };
133                Ok(Decision::Violate { severity, message })
134            }
135            Ok(other) => Err(format!("returned {}", other.type_name())),
136            Err(e) => {
137                let text = e.to_string();
138                if text.contains(BUDGET_MESSAGE) {
139                    Err(format!(
140                        "exceeded its instruction budget of {CALLBACK_INSTRUCTION_BUDGET} instructions \
141                         (possible infinite loop)"
142                    ))
143                } else {
144                    Err(text)
145                }
146            }
147        }
148    }
149}
150
151/// A compiled Lua parser: finds all matches for a rule in one call.
152///
153/// The snippet is `return function(text, offset) ... end` — `text` is
154/// the whole scan unit (the file for global rules, the region for
155/// scoped rules), `offset` its absolute byte position. It returns
156/// `nil` or a list of `{ start = , finish = , captures = { ... } }`
157/// tables with positions **relative to `text`**.
158pub struct LuaParser {
159    lua: Arc<Lua>,
160    function: Function,
161}
162
163impl MatchParser for LuaParser {
164    fn find(&self, text: &str, offset: usize) -> Result<Vec<RawMatch>, String> {
165        let lua = &self.lua;
166        lua.set_hook(
167            mlua::HookTriggers::new().every_nth_instruction(PARSER_INSTRUCTION_BUDGET),
168            |_lua, _debug| Err(mlua::Error::RuntimeError(BUDGET_MESSAGE.to_string())),
169        )
170        .map_err(|e| e.to_string())?;
171        let call = self.function.call::<Value>((text, offset));
172        lua.remove_hook();
173        let result = call.map_err(|e| {
174            let text = e.to_string();
175            if text.contains(BUDGET_MESSAGE) {
176                format!(
177                    "exceeded its instruction budget of {PARSER_INSTRUCTION_BUDGET} instructions \
178                     (possible infinite loop)"
179                )
180            } else {
181                text
182            }
183        })?;
184
185        let Value::Nil = result else {
186            let Value::Table(entries) = result else {
187                return Err(format!(
188                    "parser must return nil or a list of matches (returned {})",
189                    result.type_name()
190                ));
191            };
192            let mut matches = Vec::new();
193            for entry in entries.sequence_values::<Value>() {
194                let entry = entry.map_err(|e| e.to_string())?;
195                let Value::Table(entry) = entry else {
196                    return Err(format!(
197                        "parser matches must be tables (found {})",
198                        entry.type_name()
199                    ));
200                };
201                let start: usize = entry.get("start").map_err(|e| e.to_string())?;
202                let finish: usize = entry.get("finish").map_err(|e| e.to_string())?;
203                if start >= finish {
204                    return Err(format!(
205                        "parser match has an empty or reversed span \
206                         (start {start} >= finish {finish})"
207                    ));
208                }
209                let mut raw = RawMatch::new(start, finish);
210                match entry.get::<Value>("captures") {
211                    Ok(Value::Nil) | Err(_) => {}
212                    Ok(Value::Table(captures)) => {
213                        for pair in captures.pairs::<String, String>() {
214                            let (name, value) = pair.map_err(|e| e.to_string())?;
215                            raw = raw.with_capture(name, value);
216                        }
217                    }
218                    Ok(other) => {
219                        return Err(format!(
220                            "`captures` must be a table of strings (found {})",
221                            other.type_name()
222                        ));
223                    }
224                }
225                matches.push(raw);
226            }
227            return Ok(matches);
228        };
229        Ok(Vec::new())
230    }
231}
232
233/// Compiles every inline and file callback and parser in `set` and
234/// registers them in `callbacks`. File paths resolve relative to each
235/// config file.
236///
237/// Syntax errors and unreadable files are reported with the config file
238/// and rule id; nothing partial is registered on failure — call sites
239/// should treat an `Err` as fatal for this config set.
240pub fn attach(set: &ConfigSet, callbacks: &mut Callbacks) -> Result<(), ConfigError> {
241    let lua = Arc::new(Lua::new());
242    route_print_to_stderr(&lua)
243        .map_err(|e| ConfigError::new(format!("cannot install the print override: {e}")))?;
244    for named in set.configs() {
245        let base = named
246            .path
247            .parent()
248            .filter(|p| !p.as_os_str().is_empty())
249            .map(std::path::Path::to_path_buf)
250            .unwrap_or_else(|| std::path::PathBuf::from("."));
251
252        let mut visit = |rules: &[declint_core::Rule],
253                         label: &dyn Fn(&declint_core::Rule) -> String|
254         -> Result<(), ConfigError> {
255            for rule in rules {
256                if let Some(reference) = &rule.callback {
257                    let compiled = compile_ref(&lua, &base, reference)
258                        .map_err(|e| invalid(label(rule), &e, "callback"))?;
259                    callbacks.register_ref(
260                        reference,
261                        Arc::new(LuaCallback {
262                            lua: Arc::clone(&lua),
263                            function: compiled,
264                        }),
265                    );
266                }
267                if let Some(reference) = &rule.parser {
268                    let compiled = compile_ref(&lua, &base, reference)
269                        .map_err(|e| invalid(label(rule), &e, "parser"))?;
270                    callbacks.register_parser_ref(
271                        reference,
272                        Arc::new(LuaParser {
273                            lua: Arc::clone(&lua),
274                            function: compiled,
275                        }),
276                    );
277                }
278            }
279            Ok(())
280        };
281
282        let global_label = |rule: &declint_core::Rule| format!("{}: rule '{}'", shown(&named.path), rule.id);
283        visit(&named.config.rules, &global_label)?;
284        for scope in &named.config.scopes {
285            let scope_label = |rule: &declint_core::Rule| {
286                format!(
287                    "{}: scope '{}' rule '{}'",
288                    shown(&named.path),
289                    scope.id,
290                    rule.id
291                )
292            };
293            visit(&scope.rules, &scope_label)?;
294        }
295    }
296    Ok(())
297}
298
299/// Compiles one inline/file snippet reference into its callback function.
300/// `Name` references belong to hosts, not to Lua — skipped here.
301fn compile_ref(
302    lua: &Lua,
303    base: &std::path::Path,
304    reference: &CallbackRef,
305) -> Result<Function, mlua::Error> {
306    match reference {
307        CallbackRef::Name(_) => unreachable!("name references are host-registered"),
308        CallbackRef::Inline { source } => compile(lua, source.clone()),
309        CallbackRef::File { path } => {
310            let source = std::fs::read_to_string(base.join(path))?;
311            compile(lua, source)
312        }
313    }
314}
315
316fn shown(path: &std::path::Path) -> String {
317    let text = path.display().to_string();
318    if text.is_empty() {
319        "<config>".to_string()
320    } else {
321        text
322    }
323}
324
325fn invalid(label: String, e: &mlua::Error, kind: &str) -> ConfigError {
326    ConfigError::new(format!("{label}: invalid {kind}: {e}"))
327}
328
329/// Compiles a snippet into the callback function. A snippet is a chunk
330/// that **returns** the function — `return function(ctx) ... end` — so
331/// helpers and locals above it just work; the chunk is evaluated once
332/// here, at attach time.
333fn compile(lua: &Lua, source: String) -> Result<Function, mlua::Error> {
334    let chunk = lua.load(source).into_function()?;
335    match chunk.call::<Value>(())? {
336        Value::Function(function) => Ok(function),
337        other => Err(mlua::Error::RuntimeError(format!(
338            "callback snippet must `return function(ctx) ... end` (returned {})",
339            other.type_name()
340        ))),
341    }
342}
343
344/// Routes the Lua `print` global to **stderr**.
345///
346/// In `serve` mode stdout is the JSON-RPC channel: a debugging
347/// `print()` from a callback would otherwise inject raw text into the
348/// protocol stream and corrupt the editor session. This keeps `print`
349/// usable for debugging — it lands in the job log / terminal instead.
350fn route_print_to_stderr(lua: &Lua) -> Result<(), mlua::Error> {
351    let tostring = lua.globals().get::<Function>("tostring")?;
352    let print = lua.create_function(move |_lua, args: mlua::MultiValue| {
353        let mut out = String::new();
354        for (i, value) in args.into_iter().enumerate() {
355            if i > 0 {
356                out.push('\t');
357            }
358            out.push_str(&tostring.call::<String>(value)?);
359        }
360        eprintln!("{out}");
361        Ok(())
362    })?;
363    lua.globals().set("print", print)
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use declint_core::{Config, Linter};
370
371    fn linter_with(yaml: &str) -> Linter {
372        static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
373        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
374        let dir = std::env::temp_dir().join(format!("declint-lua-{}-{n}-t", std::process::id()));
375        let _ = std::fs::remove_dir_all(&dir);
376        std::fs::create_dir_all(&dir).unwrap();
377        std::fs::write(dir.join(".declint.yaml"), yaml).unwrap();
378        let set = ConfigSet::discover(&dir).unwrap();
379        let mut callbacks = Callbacks::new();
380        attach(&set, &mut callbacks).unwrap();
381        Linter::new(set.configs()[0].config.clone(), &callbacks).unwrap()
382    }
383
384    fn config_yaml(rule: &str) -> String {
385        format!("version: 1\nrules:\n  - id: probe\n    pattern: 'TODO(?<word>\\w*)'\n{rule}")
386    }
387
388    #[test]
389    fn inline_return_table_violates() {
390        let linter = linter_with(&config_yaml(
391            "    callback: |\n      return function(c)\n        return { severity = \"hint\", message = \"seen \" .. c.match .. \" at \" .. c.line .. \":\" .. c.col }\n      end\n",
392        ));
393        let v = linter.lint_in(
394            declint_core::DocInfo { path: "f.txt", language: "text" },
395            "ab\nTODOhere",
396        );
397        assert_eq!(v.len(), 1);
398        assert_eq!(v[0].severity, Severity::Hint);
399        assert_eq!(v[0].message, "seen TODOhere at 2:1");
400    }
401
402    #[test]
403    fn inline_nil_allows() {
404        let linter = linter_with(&config_yaml(
405            "    callback: |\n      return function(c) return nil end\n",
406        ));
407        assert!(linter.lint("TODO").is_empty());
408    }
409
410    #[test]
411    fn inline_true_uses_rule_message() {
412        let linter = linter_with(&config_yaml(
413            "    callback: |\n      return function(c) return true end\n    message: \"default for '{match}'\"\n",
414        ));
415        let v = linter.lint("TODOx");
416        assert_eq!(v[0].message, "default for 'TODOx'");
417    }
418
419    #[test]
420    fn captures_table_is_populated() {
421        let yaml = "\
422version: 1
423rules:
424  - id: probe
425    pattern: 'TODO(\\w+)'
426    callback: |
427      return function(c)
428        if c.captures[\"1\"] == \"fix\" then
429          return { message = \"numbered works\" }
430        end
431        return nil
432      end
433";
434        let linter = linter_with(yaml);
435        let v = linter.lint("TODOfix");
436        assert_eq!(v[0].message, "numbered works");
437    }
438
439    #[test]
440    fn context_fields_are_exposed() {
441        let linter = linter_with(&config_yaml(
442            "    callback: |\n      return function(c)\n        return { message = c.rule .. \"/\" .. c.language .. \"/\" .. c.path .. \"/\" .. c.match .. \"/\" .. c.start .. \"-\" .. c.finish }\n      end\n",
443        ));
444        let v = linter.lint_in(
445            declint_core::DocInfo { path: "p.sh", language: "sh" },
446            "go TODO go",
447        );
448        assert_eq!(v[0].message, "probe/sh/p.sh/TODO/3-7");
449    }
450
451    #[test]
452    fn lua_error_surfaces_without_crashing() {
453        let linter = linter_with(&config_yaml(
454            "    callback: |\n      return function(c) error(\"boom \" .. c.rule) end\n",
455        ));
456        let v = linter.lint("TODO");
457        assert_eq!(v[0].severity, Severity::Error);
458        let message = &v[0].message;
459        assert!(message.contains("callback error"), "{message}");
460        assert!(message.contains("boom probe"), "{message}");
461    }
462
463    #[test]
464    fn runaway_loop_hits_the_budget() {
465        let linter = linter_with(&config_yaml(
466            "    callback: |\n      return function(c) while true do end end\n",
467        ));
468        let v = linter.lint("TODO");
469        assert_eq!(v[0].severity, Severity::Error);
470        assert!(
471            v[0].message.contains("instruction budget"),
472            "{}",
473            v[0].message
474        );
475    }
476
477    #[test]
478    fn bad_return_shapes_are_reported() {
479        let linter = linter_with(&config_yaml(
480            "    callback: |\n      return function(c) return 42 end\n",
481        ));
482        let v = linter.lint("TODO");
483        assert!(v[0].message.contains("returned"), "{}", v[0].message);
484
485        let linter = linter_with(&config_yaml(
486            "    callback: |\n      return function(c) return { severity = \"hint\" } end\n",
487        ));
488        let v = linter.lint("TODO");
489        assert!(v[0].message.contains("without a `message`"), "{}", v[0].message);
490    }
491
492    #[test]
493    fn syntax_error_is_a_config_error() {
494        let dir = std::env::temp_dir().join(format!("declint-lua-{}-syn", std::process::id()));
495        let _ = std::fs::remove_dir_all(&dir);
496        std::fs::create_dir_all(&dir).unwrap();
497        std::fs::write(
498            dir.join(".declint.yaml"),
499            "version: 1\nrules:\n  - id: probe\n    pattern: 'x'\n    callback: 'returnnil junk'\n",
500        )
501        .unwrap();
502        let set = ConfigSet::discover(&dir).unwrap();
503        let mut callbacks = Callbacks::new();
504        let e = attach(&set, &mut callbacks).unwrap_err();
505        assert!(e.to_string().contains("rule 'probe'"), "{e}");
506        assert!(e.to_string().contains("invalid callback"), "{e}");
507        std::fs::remove_dir_all(&dir).unwrap();
508    }
509
510    #[test]
511    fn file_callback_resolves_relative_to_config() {
512        let dir = std::env::temp_dir().join(format!("declint-lua-{}-file", std::process::id()));
513        let _ = std::fs::remove_dir_all(&dir);
514        std::fs::create_dir_all(dir.join("checks")).unwrap();
515        std::fs::write(
516            dir.join(".declint.yaml"),
517            "version: 1\nrules:\n  - id: probe\n    pattern: 'TODO'\n    callback: checks/cb.lua\n",
518        )
519        .unwrap();
520        std::fs::write(
521            dir.join("checks").join("cb.lua"),
522            "return function(c) return { message = \"from file: \" .. c.match } end\n",
523        )
524        .unwrap();
525        let set = ConfigSet::discover(&dir).unwrap();
526        let mut callbacks = Callbacks::new();
527        attach(&set, &mut callbacks).unwrap();
528        let linter = Linter::new(set.configs()[0].config.clone(), &callbacks).unwrap();
529        let v = linter.lint("TODO");
530        assert_eq!(v[0].message, "from file: TODO");
531        std::fs::remove_dir_all(&dir).unwrap();
532    }
533
534    #[test]
535    fn plain_configs_need_no_lua() {
536        let config = Config::from_str(
537            "version: 1\nrules:\n  - id: r\n    pattern: x\n    message: m\n",
538        )
539        .unwrap();
540        let dir = std::env::temp_dir().join(format!("declint-lua-{}-plain", std::process::id()));
541        let _ = std::fs::remove_dir_all(&dir);
542        std::fs::create_dir_all(&dir).unwrap();
543        std::fs::write(dir.join(".declint.yaml"), "version: 1\nrules:\n  - id: r\n    pattern: x\n    message: m\n").unwrap();
544        let set = ConfigSet::discover(&dir).unwrap();
545        let mut callbacks = Callbacks::new();
546        attach(&set, &mut callbacks).unwrap();
547        assert!(Linter::new(config, &callbacks).is_ok());
548        std::fs::remove_dir_all(&dir).unwrap();
549    }
550}
551
552#[cfg(test)]
553mod parser_tests {
554    use super::*;
555    use declint_core::{DocInfo, Linter};
556
557    fn linter_with(yaml: &str) -> Linter {
558        static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
559        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
560        let dir = std::env::temp_dir().join(format!("declint-lua-{}-{n}-p", std::process::id()));
561        let _ = std::fs::remove_dir_all(&dir);
562        std::fs::create_dir_all(&dir).unwrap();
563        std::fs::write(dir.join(".declint.yaml"), yaml).unwrap();
564        let set = ConfigSet::discover(&dir).unwrap();
565        let mut callbacks = Callbacks::new();
566        attach(&set, &mut callbacks).unwrap();
567        Linter::new(set.configs()[0].config.clone(), &callbacks).unwrap()
568    }
569
570    /// The canonical parser-rule demo: INI duplicate keys. Regex rules
571    /// cannot count; a parser rule sees the whole file at once.
572    fn duplicate_keys_yaml() -> String {
573        "\
574version: 1
575rules:
576  - id: duplicate-keys
577    parser: |
578      return function(text, offset)
579        local seen, matches = {}, {}
580        local pos = 1
581        while pos <= #text do
582          local nl = text:find('\\n', pos, true) or (#text + 1)
583          local line = text:sub(pos, nl - 1)
584          local key = line:match('^%s*([%w-]+)%s*=')
585          if key and seen[key] then
586            matches[#matches + 1] = {
587              start = offset + pos - 1, finish = offset + pos - 1 + #line,
588              captures = { key = key, count = tostring(seen[key] + 1) },
589            }
590          end
591          if key then seen[key] = (seen[key] or 0) + 1 end
592          pos = nl + 1
593        end
594        return matches
595      end
596    message: \"'{key}' defined {count} times\"
597    severity: error
598"
599        .to_string()
600    }
601
602    #[test]
603    fn duplicate_keys_are_detectable_at_last() {
604        let linter = linter_with(&duplicate_keys_yaml());
605        let v = linter.lint_in(
606            DocInfo { path: "app.ini", language: "ini" },
607            "[server]\nport = 1\nport = 2\nport = 3\n",
608        );
609        // port appears 3 times: the 2nd and 3rd definitions are flagged,
610        // each stating how many times the key is now defined.
611        assert_eq!(v.len(), 2);
612        assert_eq!(v[0].message, "'port' defined 2 times");
613        assert_eq!(v[1].message, "'port' defined 3 times");
614        assert_eq!(v[0].span.to_range(), 18..26);
615        assert_eq!(v[1].span.to_range(), 27..35);
616    }
617
618    #[test]
619    fn parser_returning_nil_finds_nothing() {
620        let linter = linter_with(
621            "version: 1\nrules:\n  - id: r\n    parser: |\n      return function(t, o) return nil end\n    message: m\n",
622        );
623        assert!(linter.lint("anything").is_empty());
624    }
625
626    #[test]
627    fn parser_runaway_loop_hits_the_budget() {
628        let linter = linter_with(
629            "version: 1\nrules:\n  - id: r\n    parser: |\n      return function(t, o) while true do end end\n    message: m\n",
630        );
631        let v = linter.lint("x");
632        assert_eq!(v[0].severity, Severity::Error);
633        assert!(
634            v[0].message.contains("instruction budget"),
635            "{}",
636            v[0].message
637        );
638    }
639
640    #[test]
641    fn invalid_parser_return_shapes_are_reported() {
642        let linter = linter_with(
643            "version: 1\nrules:\n  - id: r\n    parser: |\n      return function(t, o) return { 42 } end\n    message: m\n",
644        );
645        let v = linter.lint("x");
646        assert!(v[0].message.contains("matches must be tables"), "{}", v[0].message);
647
648        let linter = linter_with(
649            "version: 1\nrules:\n  - id: r\n    parser: |\n      return function(t, o) return { { start = 5, finish = 2 } } end\n    message: m\n",
650        );
651        let v = linter.lint("x");
652        assert!(
653            v[0].message.contains("empty or reversed span"),
654            "{}",
655            v[0].message
656        );
657
658        let linter = linter_with(
659            "version: 1\nrules:\n  - id: r\n    parser: |\n      return function(t, o) return 'nope' end\n    message: m\n",
660        );
661        let v = linter.lint("x");
662        assert!(
663            v[0].message.contains("must return nil or a list"),
664            "{}",
665            v[0].message
666        );
667    }
668
669    #[test]
670    fn parser_syntax_error_is_a_config_error() {
671        let dir = std::env::temp_dir().join(format!("declint-lua-{}-psyn", std::process::id()));
672        let _ = std::fs::remove_dir_all(&dir);
673        std::fs::create_dir_all(&dir).unwrap();
674        std::fs::write(
675            dir.join(".declint.yaml"),
676            "version: 1\nrules:\n  - id: r\n    parser: 'return function(t, o) returnnil end'\n    message: m\n",
677        )
678        .unwrap();
679        let set = ConfigSet::discover(&dir).unwrap();
680        let mut callbacks = Callbacks::new();
681        let e = attach(&set, &mut callbacks).unwrap_err();
682        assert!(e.to_string().contains("rule 'r'"), "{e}");
683        assert!(e.to_string().contains("invalid parser"), "{e}");
684        std::fs::remove_dir_all(&dir).unwrap();
685    }
686
687    #[test]
688    fn parser_template_interpolates_parser_captures() {
689        let linter = linter_with(
690            "version: 1\nrules:\n  - id: r\n    parser: |\n      return function(t, o)\n        return { { start = 0, finish = 3, captures = { who = 'parser', n = '7' } } }\n      end\n    message: '{who} found {n} things'\n",
691        );
692        let v = linter.lint("abc def");
693        assert_eq!(v[0].message, "parser found 7 things");
694    }
695}
696
697#[cfg(test)]
698mod import_tests {
699    use super::*;
700    use declint_core::{DocInfo, Linter};
701
702    #[test]
703    fn callback_inside_an_imported_file_fires() {
704        static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
705        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
706        let dir = std::env::temp_dir().join(format!("declint-lua-{}-{n}-imp", std::process::id()));
707        let _ = std::fs::remove_dir_all(&dir);
708        std::fs::create_dir_all(&dir).unwrap();
709        std::fs::write(
710            dir.join("lib.yaml"),
711            "version: 1\nrules:\n  - id: loud\n    pattern: 'TODO(!+)'\n    message: 'bangs: {match}'\n    callback: |\n      return function(c) return { message = c.captures[\"1\"] } end\n",
712        )
713        .unwrap();
714        std::fs::write(
715            dir.join(".declint.yaml"),
716            "version: 1\nimport:\n  - lib.yaml\n",
717        )
718        .unwrap();
719        let set = ConfigSet::discover(&dir).unwrap();
720        let mut callbacks = Callbacks::new();
721        attach(&set, &mut callbacks).unwrap();
722        let linter = Linter::new(set.configs()[0].config.clone(), &callbacks).unwrap();
723        let v = linter.lint_in(
724            DocInfo { path: "a.md", language: "markdown" },
725            "TODO!!",
726        );
727        assert_eq!(v.len(), 1);
728        assert_eq!(v[0].message, "!!");
729    }
730
731    #[test]
732    fn imported_python_preset_scope_handles_async_defs() {
733        static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
734        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
735        let dir = std::env::temp_dir().join(format!("declint-lua-{}-{n}-async", std::process::id()));
736        let _ = std::fs::remove_dir_all(&dir);
737        std::fs::create_dir_all(&dir).unwrap();
738        std::fs::write(
739            dir.join(".declint.yaml"),
740            "version: 1\nlanguages: [python]\nimport:\n  - preset:python\n",
741        )
742        .unwrap();
743        let set = ConfigSet::discover(&dir).unwrap();
744        let mut callbacks = Callbacks::new();
745        attach(&set, &mut callbacks).unwrap();
746        let linter = Linter::new(set.configs()[0].config.clone(), &callbacks).unwrap();
747
748        let source = "async def go():\n    print(1)\n\nprint(2)\n";
749        let v = linter.lint_all_in(
750            DocInfo { path: "app.py", language: "python" },
751            source,
752        );
753        // The print inside `async def go` is flagged; the top-level one is not.
754        assert_eq!(v.len(), 1);
755        assert_eq!(v[0].rule_id, "print-in-function");
756        assert_eq!(v[0].span.to_range(), 20..26);
757    }
758}