1pub mod config;
8pub mod fetch;
9pub mod lints;
10pub mod modifiers;
11pub mod reporters;
12pub mod tree;
13
14use config::Override;
15use eipw_snippets::{Annotation, Level, Snippet};
16
17use comrak::arena_tree::Node;
18use comrak::nodes::Ast;
19use comrak::Arena;
20use formatx::formatx;
21use lints::DefaultLint;
22use modifiers::DefaultModifier;
23
24use crate::config::Options;
25use crate::lints::{Context, Error as LintError, FetchContext, InnerContext, Lint};
26use crate::modifiers::Modifier;
27use crate::reporters::Reporter;
28
29use educe::Educe;
30
31use eipw_preamble::{Preamble, SplitError};
32
33use snafu::{ensure, ResultExt, Snafu};
34
35use std::cell::RefCell;
36use std::collections::hash_map::{self, HashMap};
37use std::path::{Path, PathBuf};
38
39#[derive(Snafu, Debug)]
40#[non_exhaustive]
41pub enum Error {
42 Lint {
43 #[snafu(backtrace)]
44 source: LintError,
45 origin: Option<PathBuf>,
46 },
47 #[snafu(context(false))]
48 Modifier {
49 #[snafu(backtrace)]
50 source: crate::modifiers::Error,
51 },
52 #[snafu(display("i/o error accessing `{}`", path.to_string_lossy()))]
53 Io {
54 path: PathBuf,
55 source: std::io::Error,
56 },
57 SliceFetched {
58 lint: String,
59 origin: Option<PathBuf>,
60 },
61}
62
63#[derive(Debug)]
64enum Source<'a> {
65 String {
66 origin: Option<&'a str>,
67 src: &'a str,
68 },
69 File(&'a Path),
70}
71
72impl<'a> Source<'a> {
73 fn origin(&self) -> Option<&Path> {
74 match self {
75 Self::String {
76 origin: Some(s), ..
77 } => Some(Path::new(s)),
78 Self::File(p) => Some(p),
79 _ => None,
80 }
81 }
82
83 fn is_string(&self) -> bool {
84 matches!(self, Self::String { .. })
85 }
86
87 async fn fetch(&self, fetch: &dyn fetch::Fetch) -> Result<String, Error> {
88 match self {
89 Self::File(f) => fetch
90 .fetch(f.to_path_buf())
91 .await
92 .with_context(|_| IoSnafu { path: f.to_owned() })
93 .map_err(Into::into),
94 Self::String { src, .. } => Ok((*src).to_owned()),
95 }
96 }
97}
98
99#[derive(Debug, Clone)]
100#[non_exhaustive]
101pub struct LintSettings<'a> {
102 _p: std::marker::PhantomData<&'a dyn Lint>,
103 pub default_annotation_level: Level,
104}
105
106#[derive(Educe)]
107#[educe(Debug)]
108#[must_use]
109pub struct Linter<'a, R> {
110 lints: HashMap<String, (Option<Level>, Box<dyn Lint>)>,
111 modifiers: Vec<Box<dyn Modifier>>,
112 sources: Vec<Source<'a>>,
113
114 proposal_format: String,
115
116 #[educe(Debug(ignore))]
117 reporter: R,
118
119 #[educe(Debug(ignore))]
120 fetch: Box<dyn fetch::Fetch>,
121}
122
123impl<'a, R> Default for Linter<'a, R>
124where
125 R: Default,
126{
127 fn default() -> Self {
128 Self::new(R::default())
129 }
130}
131
132impl<'a, R> Linter<'a, R> {
133 pub fn with_options<M, L>(reporter: R, options: Options<M, L>) -> Self
134 where
135 L: 'static + Lint,
136 M: 'static + Modifier,
137 {
138 let lints = options
139 .lints
140 .into_iter()
141 .filter_map(|(slug, toggle)| Some((slug, (None, Box::new(toggle.into_lint()?) as _))))
142 .collect();
143
144 let proposal_format = options
145 .fetch
146 .map(|o| o.proposal_format)
147 .unwrap_or_else(|| "eip-{}".into());
148
149 Self {
150 reporter,
151 sources: Default::default(),
152 fetch: Box::<fetch::DefaultFetch>::default(),
153 modifiers: options
154 .modifiers
155 .into_iter()
156 .map(|m| Box::new(m) as _)
157 .collect(),
158 lints,
159 proposal_format,
160 }
161 }
162
163 pub fn with_modifiers<I, M>(reporter: R, modifiers: I) -> Self
164 where
165 I: IntoIterator<Item = M>,
166 M: 'static + Modifier,
167 {
168 let defaults =
169 Options::<DefaultModifier<&'static str>, DefaultLint<&'static str>>::default();
170 Self::with_options(
171 reporter,
172 Options {
173 modifiers: modifiers.into_iter().collect(),
174 lints: defaults.lints,
175 fetch: defaults.fetch,
176 },
177 )
178 }
179
180 pub fn with_lints<I, S, L>(reporter: R, lints: I) -> Self
181 where
182 S: Into<String>,
183 I: IntoIterator<Item = (S, L)>,
184 L: 'static + Lint,
185 {
186 let defaults =
187 Options::<DefaultModifier<&'static str>, DefaultLint<&'static str>>::default();
188 Self::with_options(
189 reporter,
190 Options {
191 modifiers: defaults.modifiers,
192 lints: lints
193 .into_iter()
194 .map(|(s, l)| (s.into(), Override::enable(l)))
195 .collect(),
196 fetch: Default::default(),
197 },
198 )
199 }
200
201 pub fn new(reporter: R) -> Self {
202 Self::with_options::<DefaultModifier<&'static str>, DefaultLint<&'static str>>(
203 reporter,
204 Options::default(),
205 )
206 }
207
208 pub fn warn<S, T>(self, slug: S, lint: T) -> Self
209 where
210 S: Into<String>,
211 T: 'static + Lint,
212 {
213 self.add_lint(Some(Level::Warning), slug, lint)
214 }
215
216 pub fn deny<S, T>(self, slug: S, lint: T) -> Self
217 where
218 S: Into<String>,
219 T: 'static + Lint,
220 {
221 self.add_lint(Some(Level::Error), slug, lint)
222 }
223
224 pub fn modify<T>(mut self, modifier: T) -> Self
225 where
226 T: 'static + Modifier,
227 {
228 self.modifiers.push(Box::new(modifier));
229 self
230 }
231
232 fn add_lint<S, T>(mut self, level: Option<Level>, slug: S, lint: T) -> Self
233 where
234 S: Into<String>,
235 T: 'static + Lint,
236 {
237 self.lints.insert(slug.into(), (level, Box::new(lint)));
238 self
239 }
240
241 pub fn allow(mut self, slug: &str) -> Self {
242 if self.lints.remove(slug).is_none() {
243 panic!("no lint with the slug: {}", slug);
244 }
245
246 self
247 }
248
249 pub fn clear_lints(mut self) -> Self {
250 self.lints.clear();
251 self
252 }
253
254 pub fn set_fetch<F>(mut self, fetch: F) -> Self
255 where
256 F: 'static + fetch::Fetch,
257 {
258 self.fetch = Box::new(fetch);
259 self
260 }
261}
262
263impl<'a, R> Linter<'a, R>
264where
265 R: Reporter,
266{
267 pub fn check_slice(mut self, origin: Option<&'a str>, src: &'a str) -> Self {
268 self.sources.push(Source::String { origin, src });
269 self
270 }
271
272 pub fn check_file(mut self, path: &'a Path) -> Self {
273 self.sources.push(Source::File(path));
274 self
275 }
276
277 pub async fn run(self) -> Result<R, Error> {
278 if self.lints.is_empty() {
279 panic!("no lints activated");
280 }
281
282 if self.sources.is_empty() {
283 panic!("no sources given");
284 }
285
286 let mut to_check = Vec::with_capacity(self.sources.len());
287 let mut fetched_eips = HashMap::new();
288
289 for source in self.sources {
290 let source_origin = source.origin().map(Path::to_path_buf);
291 let source_content = source.fetch(&*self.fetch).await?;
292
293 to_check.push((source_origin, source_content));
294
295 let (source_origin, source_content) = to_check.last().unwrap();
296 let display_origin = source_origin.as_deref().map(Path::to_string_lossy);
297 let display_origin = display_origin.as_deref();
298
299 let arena = Arena::new();
300 let inner = match process(&reporters::Null, &arena, display_origin, source_content)? {
301 Some(i) => i,
302 None => continue,
303 };
304
305 for (slug, lint) in &self.lints {
306 let context = FetchContext {
307 body: inner.body,
308 preamble: &inner.preamble,
309 fetch_proposals: Default::default(),
310 };
311
312 lint.1
313 .find_resources(&context)
314 .with_context(|_| LintSnafu {
315 origin: source_origin.clone(),
316 })?;
317
318 let fetch_proposals = context.fetch_proposals.into_inner();
319
320 ensure!(
325 fetch_proposals.is_empty() || !source.is_string(),
326 SliceFetchedSnafu {
327 lint: slug,
328 origin: source_origin.clone(),
329 }
330 );
331
332 if fetch_proposals.is_empty() {
333 continue;
334 }
335
336 let source_path = match source {
337 Source::File(p) => p,
338 _ => unreachable!(),
339 };
340 let source_dir = source_path.parent().unwrap_or_else(|| Path::new("."));
341 let root = match source_path.file_name() {
342 Some(f) if f == "index.md" => source_dir.join(".."),
343 Some(_) | None => source_dir.to_path_buf(),
344 };
345
346 for proposal in fetch_proposals.into_iter() {
347 let entry = match fetched_eips.entry(proposal) {
348 hash_map::Entry::Occupied(_) => continue,
349 hash_map::Entry::Vacant(v) => v,
350 };
351 let basename =
352 formatx!(&self.proposal_format, proposal).expect("bad proposal format");
353
354 let mut plain_path = root.join(&basename);
355 plain_path.set_extension("md");
356 let plain = Source::File(&plain_path).fetch(&*self.fetch).await;
357
358 let mut index_path = root.join(&basename);
359 index_path.push("index.md");
360 let index = Source::File(&index_path).fetch(&*self.fetch).await;
361
362 let content = match (plain, index) {
363 (Ok(_), Ok(_)) => panic!(
364 "ambiguous proposal between `{}` and `{}`",
365 plain_path.to_string_lossy(),
366 index_path.to_string_lossy()
367 ),
368 (Ok(c), Err(_)) => Ok(c),
369 (Err(_), Ok(c)) => Ok(c),
370 (Err(e), Err(_)) => Err(e),
371 };
372
373 entry.insert(content);
374 }
375 }
376 }
377
378 let resources_arena = Arena::new();
379 let mut parsed_eips = HashMap::new();
380
381 for (number, result) in &fetched_eips {
382 let source = match result {
383 Ok(o) => o,
384 Err(e) => {
385 parsed_eips.insert(*number, Err(e));
386 continue;
387 }
388 };
389
390 let inner = match process(&self.reporter, &resources_arena, None, source)? {
391 Some(s) => s,
392 None => return Ok(self.reporter),
393 };
394 parsed_eips.insert(*number, Ok(inner));
395 }
396
397 let mut lints: Vec<_> = self.lints.iter().collect();
398 lints.sort_by_key(|l| l.0);
399
400 for (origin, source) in &to_check {
401 let display_origin = origin.as_ref().map(|p| p.to_string_lossy().into_owned());
402 let display_origin = display_origin.as_deref();
403
404 let arena = Arena::new();
405 let inner = match process(&self.reporter, &arena, display_origin, source)? {
406 Some(i) => i,
407 None => continue,
408 };
409
410 let mut settings = LintSettings {
411 _p: std::marker::PhantomData,
412 default_annotation_level: Level::Error,
413 };
414
415 for modifier in &self.modifiers {
416 let context = Context {
417 inner: inner.clone(),
418 reporter: &self.reporter,
419 eips: &parsed_eips,
420 annotation_level: settings.default_annotation_level,
421 };
422
423 modifier.modify(&context, &mut settings)?;
424 }
425
426 for (slug, (annotation_level, lint)) in &lints {
427 let annotation_level =
428 annotation_level.unwrap_or(settings.default_annotation_level);
429 let context = Context {
430 inner: inner.clone(),
431 reporter: &self.reporter,
432 eips: &parsed_eips,
433 annotation_level,
434 };
435
436 lint.lint(slug, &context).with_context(|_| LintSnafu {
437 origin: origin.clone(),
438 })?;
439 }
440 }
441
442 Ok(self.reporter)
443 }
444}
445
446fn comrak_options() -> comrak::Options<'static> {
447 comrak::Options {
448 extension: comrak::ExtensionOptions {
449 table: true,
450 autolink: true,
451 footnotes: true,
452 ..Default::default()
453 },
454 ..Default::default()
455 }
456}
457
458fn process<'a>(
459 reporter: &dyn Reporter,
460 arena: &'a Arena<Node<'a, RefCell<Ast>>>,
461 origin: Option<&'a str>,
462 source: &'a str,
463) -> Result<Option<InnerContext<'a>>, Error> {
464 let (preamble_source, body_source) = match Preamble::split(source) {
465 Ok(v) => v,
466 Err(SplitError::MissingStart { .. }) | Err(SplitError::LeadingGarbage { .. }) => {
467 let mut footer = Vec::new();
468 if source.as_bytes().get(3) == Some(&b'\r') {
469 footer.push(Level::Help.title(
470 "found a carriage return (CR), use Unix-style line endings (LF) instead",
471 ));
472 }
473 reporter
474 .report(
475 Level::Error
476 .title("first line must be `---` exactly")
477 .snippet(
478 Snippet::source(source.lines().next().unwrap_or_default())
479 .origin_opt(origin)
480 .fold(false)
481 .line_start(1),
482 )
483 .footers(footer),
484 )
485 .map_err(LintError::from)
486 .with_context(|_| LintSnafu {
487 origin: origin.map(PathBuf::from),
488 })?;
489 return Ok(None);
490 }
491 Err(SplitError::MissingEnd { .. }) => {
492 reporter
493 .report(
494 Level::Error
495 .title("preamble must be followed by a line containing `---` exactly"),
496 )
497 .map_err(LintError::from)
498 .with_context(|_| LintSnafu {
499 origin: origin.map(PathBuf::from),
500 })?;
501 return Ok(None);
502 }
503 };
504
505 let preamble = match Preamble::parse(origin, preamble_source) {
506 Ok(p) => p,
507 Err(e) => {
508 for snippet in e.into_errors() {
509 reporter
510 .report(snippet)
511 .map_err(LintError::from)
512 .with_context(|_| LintSnafu {
513 origin: origin.map(PathBuf::from),
514 })?;
515 }
516 Preamble::default()
517 }
518 };
519
520 let options = comrak_options();
521
522 let mut preamble_lines = preamble_source.matches('\n').count();
523 preamble_lines += 3;
524
525 let body = comrak::parse_document(arena, body_source, &options);
526
527 for node in body.descendants() {
528 let mut data = node.data.borrow_mut();
529 if data.sourcepos.start.line == 0 {
530 if let Some(parent) = node.parent() {
531 data.sourcepos.start.line = parent.data.borrow().sourcepos.start.line;
533 }
534 } else {
535 data.sourcepos.start.line += preamble_lines;
536 }
537
538 if data.sourcepos.end.line == 0 {
539 data.sourcepos.end.line = data.sourcepos.start.line;
540 } else {
541 data.sourcepos.end.line += preamble_lines;
542 }
543 }
544
545 Ok(Some(InnerContext {
546 body,
547 source,
548 body_source,
549 preamble,
550 origin,
551 }))
552}
553
554trait SnippetExt<'a> {
555 fn origin_opt(self, origin: Option<&'a str>) -> Self;
556}
557
558impl<'a> SnippetExt<'a> for Snippet<'a> {
559 fn origin_opt(self, origin: Option<&'a str>) -> Self {
560 match origin {
561 Some(origin) => self.origin(origin),
562 None => self,
563 }
564 }
565}
566
567trait LevelExt {
568 fn span_utf8(self, text: &str, start: usize, min_len: usize) -> Annotation;
569}
570
571impl LevelExt for Level {
572 fn span_utf8(self, text: &str, start: usize, min_len: usize) -> Annotation {
573 let end = ceil_char_boundary(text, start + min_len);
574 self.span(start..end)
575 }
576}
577
578fn ceil_char_boundary(text: &str, index: usize) -> usize {
580 if index > text.len() {
581 return text.len();
582 }
583
584 for pos in index..=text.len() {
585 if text.is_char_boundary(pos) {
586 return pos;
587 }
588 }
589
590 unreachable!();
591}