Skip to main content

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}