tnipv_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 fetch;
8pub mod lints;
9pub mod modifiers;
10pub mod reporters;
11pub mod tree;
12
13use annotate_snippets::snippet::{Annotation, AnnotationType, Slice, Snippet};
14
15use comrak::arena_tree::Node;
16use comrak::nodes::Ast;
17use comrak::{Arena, ComrakExtensionOptions, ComrakOptions};
18
19use crate::lints::{Context, DefaultLint, Error as LintError, FetchContext, InnerContext, Lint};
20use crate::modifiers::{DefaultModifier, Modifier};
21use crate::reporters::Reporter;
22
23use educe::Educe;
24
25use tnipv_preamble::{Preamble, SplitError};
26
27use serde::{Deserialize, Serialize};
28
29use snafu::{ensure, ResultExt, Snafu};
30
31use std::cell::RefCell;
32use std::collections::hash_map::{self, HashMap};
33use std::path::{Path, PathBuf};
34
35#[derive(Snafu, Debug)]
36#[non_exhaustive]
37pub enum Error {
38    Lint {
39        #[snafu(backtrace)]
40        source: LintError,
41        origin: Option<PathBuf>,
42    },
43    #[snafu(context(false))]
44    Modifier {
45        #[snafu(backtrace)]
46        source: crate::modifiers::Error,
47    },
48    Io {
49        path: PathBuf,
50        source: std::io::Error,
51    },
52    SliceFetched {
53        lint: String,
54        origin: Option<PathBuf>,
55    },
56}
57
58#[doc(hidden)]
59/// No stability guaranteed.
60pub fn default_modifiers_enum() -> Vec<DefaultModifier<&'static str>> {
61    vec![
62        DefaultModifier::SetDefaultAnnotation(modifiers::SetDefaultAnnotation {
63            name: "status",
64            value: "Stagnant",
65            annotation_type: AnnotationType::Warning,
66        }),
67        DefaultModifier::SetDefaultAnnotation(modifiers::SetDefaultAnnotation {
68            name: "status",
69            value: "Withdrawn",
70            annotation_type: AnnotationType::Warning,
71        }),
72    ]
73}
74
75pub fn default_modifiers() -> impl Iterator<Item = Box<dyn Modifier>> {
76    default_modifiers_enum().into_iter().map(|m| match m {
77        DefaultModifier::SetDefaultAnnotation(m) => Box::new(m) as Box<dyn Modifier>,
78    })
79}
80
81pub fn default_lints() -> impl Iterator<Item = (&'static str, Box<dyn Lint>)> {
82    default_lints_enum().map(|(name, lint)| (name, lint.boxed()))
83}
84
85#[doc(hidden)]
86/// No stability guaranteed.
87pub fn default_lints_enum() -> impl Iterator<Item = (&'static str, DefaultLint<&'static str>)> {
88    use self::DefaultLint::*;
89    use lints::preamble::regex;
90    use lints::{markdown, preamble};
91
92    [
93        //
94        // Preamble
95        //
96        ("preamble-no-dup", PreambleNoDuplicates(preamble::NoDuplicates)),
97        ("preamble-trim", PreambleTrim(preamble::Trim)),
98        ("preamble-tnip", PreambleUint { name: preamble::Uint("tnip") }),
99        ("preamble-author", PreambleAuthor { name: preamble::Author("author") } ),
100        ("preamble-re-title", PreambleRegex(preamble::Regex {
101            name: "title",
102            mode: regex::Mode::Excludes,
103            pattern: r"(?i)standar\w*\b",
104            message: "preamble header `title` should not contain `standard` (or similar words.)",
105        })),
106        ("preamble-re-title-colon", PreambleRegex(preamble::Regex {
107            name: "title",
108            mode: regex::Mode::Excludes,
109            pattern: r":",
110            message: "preamble header `title` should not contain `:`",
111        })),
112        (
113            "preamble-refs-title",
114            PreambleProposalRef(preamble::ProposalRef {
115                name: "title",
116                prefix: "tnip-",
117                suffix: ".md",
118            }),
119        ),
120        (
121            "preamble-refs-description",
122            PreambleProposalRef(preamble::ProposalRef {
123                name: "description",
124                prefix: "tnip-",
125                suffix: ".md",
126            }),
127        ),
128        (
129            "preamble-re-title-tnip-dash",
130            PreambleRegex(preamble::Regex {
131                name: "title",
132                mode: regex::Mode::Excludes,
133                pattern: r"(?i)tnip[\s]*[0-9]+",
134                message: "proposals must be referenced with the form `TNIP-N` (not `TNIPN` or `TNIP N`)",
135            }),
136        ),
137        (
138            "preamble-re-description-tnip-dash",
139            PreambleRegex(preamble::Regex {
140                name: "description",
141                mode: regex::Mode::Excludes,
142                pattern: r"(?i)tnip[\s]*[0-9]+",
143                message: "proposals must be referenced with the form `TNIP-N` (not `TNIPN` or `TNIP N`)",
144            }),
145        ),
146        ("preamble-re-description", PreambleRegex(preamble::Regex {
147            name: "description",
148            mode: regex::Mode::Excludes,
149            pattern: r"(?i)standar\w*\b",
150            message: "preamble header `description` should not contain `standard` (or similar words.)",
151        })),
152        ("preamble-re-description-colon", PreambleRegex(preamble::Regex {
153            name: "description",
154            mode: regex::Mode::Excludes,
155            pattern: r":",
156            message: "preamble header `description` should not contain `:`",
157        })),
158        (
159            "preamble-discussions-to",
160            PreambleUrl { name: preamble::Url("discussions-to") },
161        ),
162        (
163            "preamble-re-discussions-to",
164            PreambleRegex(preamble::Regex {
165                name: "discussions-to",
166                mode: regex::Mode::Includes,
167                pattern: "^https://forum.telcoin.org/t/[^/]+/[0-9]+$",
168                message: concat!(
169                    "preamble header `discussions-to` should ",
170                    "point to a thread on forum.telcoin.org/tnips"
171                ),
172            }),
173        ),
174        ("preamble-list-author", PreambleList { name: preamble::List("author") }),
175        ("preamble-list-requires", PreambleList{name: preamble::List("requires")}),
176        (
177            "preamble-len-requires",
178            PreambleLength(preamble::Length {
179                name: "requires",
180                min: Some(1),
181                max: None,
182            }
183            ),
184        ),
185        (
186            "preamble-uint-requires",
187            PreambleUintList { name: preamble::UintList("requires") },
188        ),
189        (
190            "preamble-len-title",
191            PreambleLength(preamble::Length {
192                name: "title",
193                min: Some(2),
194                max: Some(44),
195            }
196            ),
197        ),
198        (
199            "preamble-len-description",
200            PreambleLength(preamble::Length {
201                name: "description",
202                min: Some(2),
203                max: Some(140),
204            }
205            ),
206        ),
207        (
208            "preamble-req",
209            PreambleRequired { names: preamble::Required(vec![
210                "tnip",
211                "title",
212                "description",
213                "author",
214                "discussions-to",
215                "status",
216                "type",
217                "created",
218            ])
219            },
220        ),
221        (
222            "preamble-order",
223            PreambleOrder { names: preamble::Order(vec![
224                "tnip",
225                "title",
226                "description",
227                "author",
228                "discussions-to",
229                "status",
230                "last-call-deadline",
231                "type",
232                "category",
233                "created",
234                "requires",
235                "withdrawal-reason",
236            ])
237            },
238        ),
239        ("preamble-date-created", PreambleDate { name: preamble::Date("created") } ),
240        (
241            "preamble-req-last-call-deadline",
242            PreambleRequiredIfEq(preamble::RequiredIfEq {
243                when: "status",
244                equals: "Last Call",
245                then: "last-call-deadline",
246            }
247            ),
248        ),
249        (
250            "preamble-date-last-call-deadline",
251            PreambleDate { name: preamble::Date("last-call-deadline") },
252        ),
253        (
254            "preamble-req-category",
255            PreambleRequiredIfEq(preamble::RequiredIfEq {
256                when: "type",
257                equals: "Standards Track",
258                then: "category",
259            }
260            ),
261        ),
262        (
263            "preamble-req-withdrawal-reason",
264            PreambleRequiredIfEq(preamble::RequiredIfEq {
265                when: "status",
266                equals: "Withdrawn",
267                then: "withdrawal-reason",
268            }
269            ),
270        ),
271        (
272            "preamble-enum-status",
273            PreambleOneOf(preamble::OneOf {
274                name: "status",
275                values: vec![
276                    "Draft",
277                    "Review",
278                    "Last Call",
279                    "Final",
280                    "Stagnant",
281                    "Withdrawn",
282                    "Living",
283                ],
284            }
285            ),
286        ),
287        (
288            "preamble-enum-type",
289            PreambleOneOf(preamble::OneOf {
290                name: "type",
291                values: vec!["Standards Track", "Meta", "Informational"],
292            }
293            ),
294        ),
295        (
296            "preamble-enum-category",
297            PreambleOneOf(preamble::OneOf {
298                name: "category",
299                values: vec!["Core", "Networking", "Interface"],
300            }
301            ),
302        ),
303        (
304            "preamble-requires-status",
305            PreambleRequiresStatus(preamble::RequiresStatus {
306                requires: "requires",
307                status: "status",
308                prefix: "tnip-",
309                suffix: ".md",
310                flow: vec![
311                    vec!["Draft", "Stagnant"],
312                    vec!["Review"],
313                    vec!["Last Call"],
314                    vec!["Final", "Withdrawn", "Living"],
315                ]
316            }),
317        ),
318        (
319            "preamble-requires-ref-title",
320            PreambleRequireReferenced(preamble::RequireReferenced {
321                name: "title",
322                requires: "requires",
323            }),
324        ),
325        (
326            "preamble-requires-ref-description",
327            PreambleRequireReferenced(preamble::RequireReferenced {
328                name: "description",
329                requires: "requires",
330            }),
331        ),
332        (
333            "preamble-file-name",
334            PreambleFileName(preamble::FileName {
335                name: "tnip",
336                prefix: "tnip-",
337                suffix: ".md",
338            }),
339        ),
340        //
341        // Markdown
342        //
343        (
344            "markdown-refs",
345            MarkdownProposalRef(markdown::ProposalRef {
346                prefix: "tnip-",
347                suffix: ".md",
348            }),
349        ),
350        (
351            "markdown-html-comments",
352            MarkdownHtmlComments(markdown::HtmlComments {
353                name: "status",
354                warn_for: vec![
355                    "Draft",
356                    "Withdrawn",
357                ],
358            }
359            ),
360        ),
361        (
362            "markdown-req-section",
363            MarkdownSectionRequired { sections: markdown::SectionRequired(vec![
364                "Abstract",
365                "Specification",
366                "Rationale",
367                "Security Considerations",
368                "Copyright",
369            ])
370            },
371        ),
372        (
373            "markdown-order-section",
374            MarkdownSectionOrder {
375                sections: markdown::SectionOrder(vec![
376                    "Abstract",
377                    "Motivation",
378                    "Specification",
379                    "Rationale",
380                    "Backwards Compatibility",
381                    "Test Cases",
382                    "Reference Implementation",
383                    "Security Considerations",
384                    "Copyright",
385                ])
386            },
387        ),
388        (
389            "markdown-re-tnip-dash",
390            MarkdownRegex(markdown::Regex {
391                mode: markdown::regex::Mode::Excludes,
392                pattern: r"(?i)tnip[\s]*[0-9]+",
393                message: "proposals must be referenced with the form `TNIP-N` (not `TNIPN` or `TNIP N`)",
394            }),
395        ),
396        (
397            "markdown-link-first",
398            MarkdownLinkFirst {
399                pattern: markdown::LinkFirst(r"(?i)(?:tnip)-[0-9]+"),
400            }
401        ),
402        ("markdown-rel-links", MarkdownRelativeLinks(markdown::RelativeLinks {
403            exceptions: vec![
404                "^https://(www\\.)?github\\.com/telcoin-association/consensus-specs/blob/[a-f0-9]{40}/.+$",
405                "^https://(www\\.)?github\\.com/telcoin-association/consensus-specs/commit/[a-f0-9]{40}$",
406
407                "^https://(www\\.)?github\\.com/telcoin-association/devp2p/blob/[0-9a-f]{40}/.+$",
408                "^https://(www\\.)?github\\.com/telcoin-association/devp2p/commit/[0-9a-f]{40}$",
409
410                "^https://(www\\.)?github\\.com/bitcoin/bips/blob/[0-9a-f]{40}/bip-[0-9]+\\.mediawiki$",
411
412                "^https://www\\.w3\\.org/TR/[0-9][0-9][0-9][0-9]/.*$",
413                "^https://[a-z]*\\.spec\\.whatwg\\.org/commit-snapshots/[0-9a-f]{40}/$",
414                "^https://www\\.rfc-editor\\.org/rfc/.*$",
415            ]
416        })),
417        (
418            "markdown-link-status",
419            MarkdownLinkStatus(markdown::LinkStatus {
420                prefix: "tnip-",
421                suffix: ".md",
422                status: "status",
423                flow: vec![
424                    vec!["Draft", "Stagnant"],
425                    vec!["Review"],
426                    vec!["Last Call"],
427                    vec!["Final", "Withdrawn", "Living"],
428                ]
429            }),
430        ),
431        (
432            "markdown-json-cite",
433            MarkdownJsonSchema(markdown::JsonSchema {
434                additional_schemas: vec![
435                    (
436                        "https://resource.citationstyles.org/schema/v1.0/input/json/csl-data.json",
437                        include_str!("lints/markdown/json_schema/csl-data.json"),
438                    ),
439                ],
440                schema: include_str!("lints/markdown/json_schema/citation.json"),
441                language: "csl-json",
442                help: concat!(
443                    "see https://github.com/telcoin-association/tnipv/blob/",
444                    "main/tnipv-lint/src/lints/markdown/",
445                    "json_schema/citation.json",
446                ),
447            }),
448        ),
449        (
450            "markdown-headings-space",
451            MarkdownHeadingsSpace(markdown::HeadingsSpace{}),
452        )
453    ]
454    .into_iter()
455}
456
457#[derive(Debug)]
458enum Source<'a> {
459    String {
460        origin: Option<&'a str>,
461        src: &'a str,
462    },
463    File(&'a Path),
464}
465
466impl<'a> Source<'a> {
467    fn origin(&self) -> Option<&Path> {
468        match self {
469            Self::String {
470                origin: Some(s), ..
471            } => Some(Path::new(s)),
472            Self::File(p) => Some(p),
473            _ => None,
474        }
475    }
476
477    fn is_string(&self) -> bool {
478        matches!(self, Self::String { .. })
479    }
480
481    async fn fetch(&self, fetch: &dyn fetch::Fetch) -> Result<String, Error> {
482        match self {
483            Self::File(f) => fetch
484                .fetch(f.to_path_buf())
485                .await
486                .with_context(|_| IoSnafu { path: f.to_owned() })
487                .map_err(Into::into),
488            Self::String { src, .. } => Ok((*src).to_owned()),
489        }
490    }
491}
492
493#[derive(Debug, Clone)]
494#[non_exhaustive]
495pub struct LintSettings<'a> {
496    _p: std::marker::PhantomData<&'a dyn Lint>,
497    pub default_annotation_type: AnnotationType,
498}
499
500struct NeverIter<T> {
501    _p: std::marker::PhantomData<fn() -> T>,
502    q: std::convert::Infallible,
503}
504
505impl<T> Iterator for NeverIter<T> {
506    type Item = T;
507
508    fn next(&mut self) -> Option<T> {
509        match self.q {}
510    }
511}
512
513#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
514#[non_exhaustive]
515pub struct Options<M, L> {
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub modifiers: Option<M>,
518
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub lints: Option<L>,
521}
522
523impl<M, L> Default for Options<M, L> {
524    fn default() -> Self {
525        Self {
526            modifiers: None,
527            lints: None,
528        }
529    }
530}
531
532impl<M, L, K> Options<Vec<M>, HashMap<K, L>>
533where
534    M: 'static + Clone + Modifier,
535    L: 'static + Clone + Lint,
536    K: AsRef<str>,
537{
538    pub fn to_iters(
539        &self,
540    ) -> Options<
541        impl Iterator<Item = Box<dyn Modifier>> + '_,
542        impl Iterator<Item = (&'_ str, Box<dyn Lint>)>,
543    > {
544        let modifiers = self
545            .modifiers
546            .as_ref()
547            .map(|m| m.iter().map(|n| Box::new(n.clone()) as Box<dyn Modifier>));
548
549        let lints = self.lints.as_ref().map(|l| {
550            l.iter()
551                .map(|(k, v)| (k.as_ref(), Box::new(v.clone()) as Box<dyn Lint>))
552        });
553
554        Options { modifiers, lints }
555    }
556}
557
558#[derive(Educe)]
559#[educe(Debug)]
560#[must_use]
561pub struct Linter<'a, R> {
562    lints: HashMap<&'a str, (Option<AnnotationType>, Box<dyn Lint>)>,
563    modifiers: Vec<Box<dyn Modifier>>,
564    sources: Vec<Source<'a>>,
565
566    #[educe(Debug(ignore))]
567    reporter: R,
568
569    #[educe(Debug(ignore))]
570    fetch: Box<dyn fetch::Fetch>,
571}
572
573impl<'a, R> Default for Linter<'a, R>
574where
575    R: Default,
576{
577    fn default() -> Self {
578        Self::new(R::default())
579    }
580}
581
582impl<'a, R> Linter<'a, R> {
583    pub fn with_options<'b, M, L>(reporter: R, options: Options<M, L>) -> Self
584    where
585        'b: 'a,
586        L: Iterator<Item = (&'b str, Box<dyn Lint>)>,
587        M: Iterator<Item = Box<dyn Modifier>>,
588    {
589        let modifiers = match options.modifiers {
590            Some(m) => m.collect(),
591            None => default_modifiers().collect(),
592        };
593
594        let lints = match options.lints {
595            Some(l) => l.map(|(slug, lint)| (slug, (None, lint))).collect(),
596            None => default_lints()
597                .map(|(slug, lint)| (slug, (None, lint)))
598                .collect(),
599        };
600
601        Self {
602            reporter,
603            sources: Default::default(),
604            fetch: Box::<fetch::DefaultFetch>::default(),
605            modifiers,
606            lints,
607        }
608    }
609
610    pub fn with_modifiers(reporter: R, modifiers: impl Iterator<Item = Box<dyn Modifier>>) -> Self {
611        Self::with_options(
612            reporter,
613            Options {
614                lints: Option::<NeverIter<_>>::None,
615                modifiers: Some(modifiers),
616            },
617        )
618    }
619
620    pub fn with_lints<'b: 'a>(
621        reporter: R,
622        lints: impl Iterator<Item = (&'b str, Box<dyn Lint>)>,
623    ) -> Self {
624        Self::with_options(
625            reporter,
626            Options {
627                modifiers: Option::<NeverIter<_>>::None,
628                lints: Some(lints),
629            },
630        )
631    }
632
633    pub fn new(reporter: R) -> Self {
634        Self::with_options::<NeverIter<_>, NeverIter<_>>(reporter, Options::default())
635    }
636
637    pub fn warn<T>(self, slug: &'a str, lint: T) -> Self
638    where
639        T: 'static + Lint,
640    {
641        self.add_lint(Some(AnnotationType::Warning), slug, lint)
642    }
643
644    pub fn deny<T>(self, slug: &'a str, lint: T) -> Self
645    where
646        T: 'static + Lint,
647    {
648        self.add_lint(Some(AnnotationType::Error), slug, lint)
649    }
650
651    pub fn modify<T>(mut self, modifier: T) -> Self
652    where
653        T: 'static + Modifier,
654    {
655        self.modifiers.push(Box::new(modifier));
656        self
657    }
658
659    fn add_lint<T>(mut self, level: Option<AnnotationType>, slug: &'a str, lint: T) -> Self
660    where
661        T: 'static + Lint,
662    {
663        self.lints.insert(slug, (level, Box::new(lint)));
664        self
665    }
666
667    pub fn allow(mut self, slug: &str) -> Self {
668        if self.lints.remove(slug).is_none() {
669            panic!("no lint with the slug: {}", slug);
670        }
671
672        self
673    }
674
675    pub fn clear_lints(mut self) -> Self {
676        self.lints.clear();
677        self
678    }
679
680    pub fn set_fetch<F>(mut self, fetch: F) -> Self
681    where
682        F: 'static + fetch::Fetch,
683    {
684        self.fetch = Box::new(fetch);
685        self
686    }
687}
688
689impl<'a, R> Linter<'a, R>
690where
691    R: Reporter,
692{
693    pub fn check_slice(mut self, origin: Option<&'a str>, src: &'a str) -> Self {
694        self.sources.push(Source::String { origin, src });
695        self
696    }
697
698    pub fn check_file(mut self, path: &'a Path) -> Self {
699        self.sources.push(Source::File(path));
700        self
701    }
702
703    pub async fn run(self) -> Result<R, Error> {
704        if self.lints.is_empty() {
705            panic!("no lints activated");
706        }
707
708        if self.sources.is_empty() {
709            panic!("no sources given");
710        }
711
712        let mut to_check = Vec::with_capacity(self.sources.len());
713        let mut fetched_tnips = HashMap::new();
714
715        for source in self.sources {
716            let source_origin = source.origin().map(Path::to_path_buf);
717            let source_content = source.fetch(&*self.fetch).await?;
718
719            to_check.push((source_origin, source_content));
720
721            let (source_origin, source_content) = to_check.last().unwrap();
722            let display_origin = source_origin.as_deref().map(Path::to_string_lossy);
723            let display_origin = display_origin.as_deref();
724
725            let arena = Arena::new();
726            let inner = match process(&reporters::Null, &arena, display_origin, source_content)? {
727                Some(i) => i,
728                None => continue,
729            };
730
731            for (slug, lint) in &self.lints {
732                let context = FetchContext {
733                    body: inner.body,
734                    preamble: &inner.preamble,
735                    tnips: Default::default(),
736                };
737
738                lint.1
739                    .find_resources(&context)
740                    .with_context(|_| LintSnafu {
741                        origin: source_origin.clone(),
742                    })?;
743
744                let tnips = context.tnips.into_inner();
745
746                // For now, string sources shouldn't be allowed to fetch external
747                // resources. The origin field isn't guaranteed to be a file/URL,
748                // and even if it was, we wouldn't know which of those to interpret
749                // it as.
750                ensure!(
751                    tnips.is_empty() || !source.is_string(),
752                    SliceFetchedSnafu {
753                        lint: *slug,
754                        origin: source_origin.clone(),
755                    }
756                );
757
758                for tnip in tnips.into_iter() {
759                    let root = match source {
760                        Source::File(p) => p.parent().unwrap_or_else(|| Path::new(".")),
761                        _ => unreachable!(),
762                    };
763
764                    let path = root.join(tnip);
765
766                    let entry = match fetched_tnips.entry(path) {
767                        hash_map::Entry::Occupied(_) => continue,
768                        hash_map::Entry::Vacant(v) => v,
769                    };
770
771                    let content = Source::File(entry.key()).fetch(&*self.fetch).await;
772                    entry.insert(content);
773                }
774            }
775        }
776
777        let resources_arena = Arena::new();
778        let mut parsed_tnips = HashMap::new();
779
780        for (origin, result) in &fetched_tnips {
781            let source = match result {
782                Ok(o) => o,
783                Err(e) => {
784                    parsed_tnips.insert(origin.as_path(), Err(e));
785                    continue;
786                }
787            };
788
789            let inner = match process(&self.reporter, &resources_arena, None, source)? {
790                Some(s) => s,
791                None => return Ok(self.reporter),
792            };
793            parsed_tnips.insert(origin.as_path(), Ok(inner));
794        }
795
796        let mut lints: Vec<_> = self.lints.iter().collect();
797        lints.sort_by_key(|l| l.0);
798
799        for (origin, source) in &to_check {
800            let display_origin = origin.as_ref().map(|p| p.to_string_lossy().into_owned());
801            let display_origin = display_origin.as_deref();
802
803            let arena = Arena::new();
804            let inner = match process(&self.reporter, &arena, display_origin, source)? {
805                Some(i) => i,
806                None => continue,
807            };
808
809            let mut settings = LintSettings {
810                _p: std::marker::PhantomData,
811                default_annotation_type: AnnotationType::Error,
812            };
813
814            for modifier in &self.modifiers {
815                let context = Context {
816                    inner: inner.clone(),
817                    reporter: &self.reporter,
818                    tnips: &parsed_tnips,
819                    annotation_type: settings.default_annotation_type,
820                };
821
822                modifier.modify(&context, &mut settings)?;
823            }
824
825            for (slug, (annotation_type, lint)) in &lints {
826                let annotation_type = annotation_type.unwrap_or(settings.default_annotation_type);
827                let context = Context {
828                    inner: inner.clone(),
829                    reporter: &self.reporter,
830                    tnips: &parsed_tnips,
831                    annotation_type,
832                };
833
834                lint.lint(slug, &context).with_context(|_| LintSnafu {
835                    origin: origin.clone(),
836                })?;
837            }
838        }
839
840        Ok(self.reporter)
841    }
842}
843
844fn process<'a>(
845    reporter: &dyn Reporter,
846    arena: &'a Arena<Node<'a, RefCell<Ast>>>,
847    origin: Option<&'a str>,
848    source: &'a str,
849) -> Result<Option<InnerContext<'a>>, Error> {
850    let (preamble_source, body_source) = match Preamble::split(source) {
851        Ok(v) => v,
852        Err(SplitError::MissingStart { .. }) | Err(SplitError::LeadingGarbage { .. }) => {
853            let mut footer = Vec::new();
854            if source.as_bytes().get(3) == Some(&b'\r') {
855                footer.push(Annotation {
856                    id: None,
857                    label: Some(
858                        "found a carriage return (CR), use Unix-style line endings (LF) instead",
859                    ),
860                    annotation_type: AnnotationType::Help,
861                });
862            }
863            reporter
864                .report(Snippet {
865                    title: Some(Annotation {
866                        id: None,
867                        label: Some("first line must be `---` exactly"),
868                        annotation_type: AnnotationType::Error,
869                    }),
870                    slices: vec![Slice {
871                        fold: false,
872                        line_start: 1,
873                        origin,
874                        source: source.lines().next().unwrap_or_default(),
875                        annotations: vec![],
876                    }],
877                    footer,
878                    ..Default::default()
879                })
880                .map_err(LintError::from)
881                .with_context(|_| LintSnafu {
882                    origin: origin.map(PathBuf::from),
883                })?;
884            return Ok(None);
885        }
886        Err(SplitError::MissingEnd { .. }) => {
887            reporter
888                .report(Snippet {
889                    title: Some(Annotation {
890                        id: None,
891                        label: Some("preamble must be followed by a line containing `---` exactly"),
892                        annotation_type: AnnotationType::Error,
893                    }),
894                    ..Default::default()
895                })
896                .map_err(LintError::from)
897                .with_context(|_| LintSnafu {
898                    origin: origin.map(PathBuf::from),
899                })?;
900            return Ok(None);
901        }
902    };
903
904    let preamble = match Preamble::parse(origin, preamble_source) {
905        Ok(p) => p,
906        Err(e) => {
907            for snippet in e.into_errors() {
908                reporter
909                    .report(snippet)
910                    .map_err(LintError::from)
911                    .with_context(|_| LintSnafu {
912                        origin: origin.map(PathBuf::from),
913                    })?;
914            }
915            Preamble::default()
916        }
917    };
918
919    let options = ComrakOptions {
920        extension: ComrakExtensionOptions {
921            table: true,
922            autolink: true,
923            footnotes: true,
924            ..Default::default()
925        },
926        ..Default::default()
927    };
928
929    let mut preamble_lines = preamble_source.matches('\n').count();
930    preamble_lines += 3;
931
932    let body = comrak::parse_document(arena, body_source, &options);
933
934    for node in body.descendants() {
935        let mut data = node.data.borrow_mut();
936        if data.sourcepos.start.line == 0 {
937            if let Some(parent) = node.parent() {
938                // XXX: This doesn't actually work.
939                data.sourcepos.start.line = parent.data.borrow().sourcepos.start.line;
940            }
941        } else {
942            data.sourcepos.start.line += preamble_lines;
943        }
944    }
945
946    Ok(Some(InnerContext {
947        body,
948        source,
949        body_source,
950        preamble,
951        origin,
952    }))
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958
959    #[test]
960    fn lints_serialize_deserialize() {
961        type DefaultLints<S> = HashMap<S, DefaultLint<S>>;
962        let config: DefaultLints<&str> = default_lints_enum().collect();
963
964        let serialized = toml::to_string_pretty(&config).unwrap();
965        toml::from_str::<DefaultLints<String>>(&serialized).unwrap();
966    }
967
968    #[test]
969    fn modifiers_serialize_deserialize() {
970        #[derive(Debug, Serialize, Deserialize)]
971        struct Wrapper<S> {
972            modifiers: Vec<DefaultModifier<S>>,
973        }
974
975        let config = Wrapper {
976            modifiers: default_modifiers_enum(),
977        };
978
979        let serialized = toml::to_string_pretty(&config).unwrap();
980        toml::from_str::<Wrapper<String>>(&serialized).unwrap();
981    }
982
983    #[test]
984    fn options_serialize_deserialize() {
985        let options = Options {
986            lints: Some(default_lints_enum().collect::<HashMap<_, _>>()),
987            modifiers: Some(default_modifiers_enum()),
988        };
989
990        type StringOptions =
991            Options<Vec<DefaultModifier<String>>, HashMap<String, DefaultLint<String>>>;
992
993        let serialized = toml::to_string_pretty(&options).unwrap();
994        let actual = toml::from_str::<StringOptions>(&serialized).unwrap();
995        let iters = actual.to_iters();
996
997        #[allow(unused_must_use)]
998        {
999            Linter::with_options(reporters::Null, iters);
1000        }
1001    }
1002}