declint_core/callback.rs
1//! Rule callbacks: user code that decides whether a match is a
2//! violation, and with what message.
3//!
4//! Core is engine-free: rules carry a [`CallbackRef`] (a name, an inline
5//! source, or a file path), hosts register [`MatchCallback`]
6//! implementations by name/ref, and [`Linter::build`] wires them up.
7//! The `declint-lua` crate compiles Lua callbacks; Rust embedders
8//! implement [`MatchCallback`] directly.
9
10use std::collections::HashMap;
11use std::fmt;
12use std::sync::Arc;
13
14use crate::Severity;
15
16/// Everything a callback knows about one match.
17#[derive(Debug, Clone)]
18pub struct MatchContext {
19 /// The linted file's path (`""` when the caller has none).
20 pub path: String,
21 /// The document's language id (`""` when the caller has none).
22 pub language: String,
23 /// The id of the rule that matched.
24 pub rule_id: String,
25 /// Absolute byte offset of the match start.
26 pub start: usize,
27 /// Absolute byte offset just past the match.
28 pub finish: usize,
29 /// 1-based line of the match start.
30 pub line: usize,
31 /// 1-based character column of the match start.
32 pub col: usize,
33 /// The text the pattern matched.
34 pub match_text: String,
35 /// Capture groups: named groups by name, numbered groups as `"1"`,
36 /// `"2"`, ... (the whole match is `match_text`).
37 pub captures: Vec<(String, String)>,
38}
39
40impl MatchContext {
41 /// A capture's text: try a named group, then a numbered one.
42 pub fn capture(&self, name: &str) -> Option<&str> {
43 self.captures
44 .iter()
45 .find(|(k, _)| k == name)
46 .map(|(_, v)| v.as_str())
47 }
48}
49
50/// What a callback decided about one match.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum Decision {
53 /// No diagnostic — the match is allowed.
54 Allow,
55 /// A violation with an explicit message (and optional severity
56 /// override; falls back to the rule's `severity`).
57 Violate {
58 /// Overrides the rule's severity when set.
59 severity: Option<Severity>,
60 /// The diagnostic message.
61 message: String,
62 },
63 /// A violation with the rule's own `message` template rendered for
64 /// this match (what Lua's `return true` means).
65 ViolateDefault,
66}
67
68/// A callback implementation. Must be pure with respect to the match:
69/// same input, same decision.
70pub trait MatchCallback: Send + Sync {
71 /// Decides one match. An `Err` surfaces as an `error`-severity
72 /// diagnostic naming the rule — the lint run itself is never
73 /// affected.
74 fn evaluate(&self, ctx: &MatchContext) -> Result<Decision, String>;
75}
76
77/// One match found by a [`MatchParser`], in coordinates relative to the
78/// scanned text (add the scan offset for absolute positions).
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct RawMatch {
81 /// Byte offset of the match start, relative to the scanned text.
82 pub start: usize,
83 /// Byte offset just past the match, relative to the scanned text.
84 pub finish: usize,
85 /// Free-form capture data: named groups for templates and callbacks.
86 pub captures: Vec<(String, String)>,
87}
88
89impl RawMatch {
90 /// Creates a match with the given span and no captures.
91 pub fn new(start: usize, finish: usize) -> Self {
92 Self {
93 start,
94 finish,
95 captures: Vec::new(),
96 }
97 }
98
99 /// Adds a named capture.
100 pub fn with_capture(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
101 self.captures.push((name.into(), value.into()));
102 self
103 }
104}
105
106/// A custom matcher: finds every hit for a rule in one scan unit (the
107/// whole file for global rules, a scope region for scoped rules).
108///
109/// This is the escape hatch beyond regexes — with the whole text in
110/// hand, a parser can count duplicates, flag absent constructs, or
111/// hand-roll any matching logic. Must be pure: same `(text, offset)`,
112/// same matches.
113pub trait MatchParser: Send + Sync {
114 /// Finds all matches. An `Err` surfaces as an `error`-severity
115 /// diagnostic naming the rule — the lint run itself is never
116 /// affected.
117 fn find(&self, text: &str, offset: usize) -> Result<Vec<RawMatch>, String>;
118}
119
120struct NoopCallback;
121
122impl MatchCallback for NoopCallback {
123 fn evaluate(&self, _ctx: &MatchContext) -> Result<Decision, String> {
124 Ok(Decision::Allow)
125 }
126}
127
128/// A set of registered callbacks and parsers, keyed by name or by
129/// [`CallbackRef`] identity (inline source / file path). Callback and
130/// parser keys live in separate namespaces — the same name can name a
131/// callback for one rule and a parser for another.
132#[derive(Default)]
133pub struct Callbacks {
134 callbacks: HashMap<String, Arc<dyn MatchCallback>>,
135 parsers: HashMap<String, Arc<dyn MatchParser>>,
136}
137
138impl Callbacks {
139 /// Creates an empty registry.
140 pub fn new() -> Self {
141 Self::default()
142 }
143
144 /// Registers a callback under a plain name (what a rule's
145 /// `callback: name` refers to).
146 pub fn register(&mut self, name: impl Into<String>, callback: Arc<dyn MatchCallback>) {
147 self.callbacks
148 .insert(format!("cb:name:{}", name.into()), callback);
149 }
150
151 /// Registers a convenience callback that allows every match of the
152 /// named rule.
153 pub fn register_allow(&mut self, name: impl Into<String>) {
154 self.register(name, Arc::new(NoopCallback));
155 }
156
157 /// Registers the callback implementation for an inline/file
158 /// reference — the loader's entry point (e.g. `declint-lua`).
159 pub fn register_ref(&mut self, reference: &CallbackRef, callback: Arc<dyn MatchCallback>) {
160 self.callbacks.insert(key_of(reference, "cb"), callback);
161 }
162
163 /// Registers a parser under a plain name (what a rule's
164 /// `parser: name` refers to).
165 pub fn register_parser(&mut self, name: impl Into<String>, parser: Arc<dyn MatchParser>) {
166 self.parsers
167 .insert(format!("parser:name:{}", name.into()), parser);
168 }
169
170 /// Registers the parser implementation for an inline/file reference.
171 pub fn register_parser_ref(&mut self, reference: &CallbackRef, parser: Arc<dyn MatchParser>) {
172 self.parsers.insert(key_of(reference, "parser"), parser);
173 }
174
175 /// Looks up the callback for a rule's reference.
176 pub fn resolve(&self, reference: &CallbackRef) -> Option<Arc<dyn MatchCallback>> {
177 self.callbacks.get(&key_of(reference, "cb")).cloned()
178 }
179
180 /// Looks up the parser for a rule's reference.
181 pub fn resolve_parser(&self, reference: &CallbackRef) -> Option<Arc<dyn MatchParser>> {
182 self.parsers.get(&key_of(reference, "parser")).cloned()
183 }
184
185 /// Whether anything (callback or parser) is registered at all.
186 pub fn is_empty(&self) -> bool {
187 self.callbacks.is_empty() && self.parsers.is_empty()
188 }
189}
190
191impl fmt::Debug for Callbacks {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 f.debug_struct("Callbacks")
194 .field("callbacks", &self.callbacks.len())
195 .field("parsers", &self.parsers.len())
196 .finish()
197 }
198}
199
200fn key_of(reference: &CallbackRef, kind: &str) -> String {
201 match reference {
202 CallbackRef::Name(name) => format!("{kind}:name:{name}"),
203 CallbackRef::File { path } => format!("{kind}:file:{path}"),
204 CallbackRef::Inline { source } => format!("{kind}:inline:{source}"),
205 }
206}
207
208/// A rule's reference to its callback or parser, as written in the
209/// config.
210///
211/// Classification of the `callback:` string:
212///
213/// * ends with `.lua` — a file path, relative to the config file;
214/// * contains whitespace or newlines — inline Lua source;
215/// * otherwise — the name of a registered callback.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub enum CallbackRef {
218 /// `callback: |` block scalar.
219 Inline {
220 /// The Lua source.
221 source: String,
222 },
223 /// `callback: checks/foo.lua`.
224 File {
225 /// The path as written, relative to the config file.
226 path: String,
227 },
228 /// `callback: my_name` — resolved from the registry at build time.
229 Name(String),
230}
231
232impl CallbackRef {
233 /// Classifies a `callback:` string.
234 pub fn parse(s: &str) -> Self {
235 if s.ends_with(".lua") {
236 Self::File { path: s.to_string() }
237 } else if s.chars().any(char::is_whitespace) {
238 Self::Inline { source: s.to_string() }
239 } else {
240 Self::Name(s.to_string())
241 }
242 }
243
244 /// A human-readable description for error messages.
245 pub fn describe(&self) -> String {
246 match self {
247 Self::Inline { .. } => "inline callback".to_string(),
248 Self::File { path } => format!("callback file '{path}'"),
249 Self::Name(name) => format!("callback '{name}'"),
250 }
251 }
252}