1#![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
65const CALLBACK_INSTRUCTION_BUDGET: u32 = 1_000_000;
67
68const PARSER_INSTRUCTION_BUDGET: u32 = 10_000_000;
73
74const BUDGET_MESSAGE: &str = "exceeded its instruction budget";
76
77pub 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
151pub 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
233pub 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
299fn 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
329fn 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
344fn 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 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 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 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}