eipw_lint/
lib.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7pub 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                // For now, string sources shouldn't be allowed to fetch external
321                // resources. The origin field isn't guaranteed to be a file/URL,
322                // and even if it was, we wouldn't know which of those to interpret
323                // it as.
324                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                // XXX: This doesn't actually work.
532                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
578/// Remove and replace with str::ceil_char_boundary if round_char_boundary stabilizes.
579fn 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}