supa_mdx_lint/
comments.rs

1use std::{
2    borrow::Cow,
3    collections::{HashMap, VecDeque},
4    hash::{Hash, Hasher},
5    sync::OnceLock,
6};
7
8use anyhow::Result;
9use bon::bon;
10use either::Either;
11use markdown::mdast::{MdxFlowExpression, Node};
12use regex::Regex;
13
14use crate::{
15    app_error::{MultiError, ParseError, ResultBoth},
16    context::Context,
17    location::{AdjustedOffset, AdjustedPoint, DenormalizedLocation, MaybeEndedLineRange},
18    parser::{CommentString, ParseResult},
19    utils::mdast::{MaybePosition, VariantName},
20};
21
22#[derive(Debug, PartialEq, Eq)]
23struct HashableMdxNode<'node> {
24    inner: &'node MdxFlowExpression,
25}
26
27impl<'node> HashableMdxNode<'node> {
28    fn new(inner: &'node MdxFlowExpression) -> Self {
29        Self { inner }
30    }
31}
32
33impl<'node> Hash for HashableMdxNode<'node> {
34    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
35        if let Some(pos) = &self.inner.position {
36            pos.start.line.hash(state);
37            pos.start.column.hash(state);
38            pos.end.line.hash(state);
39            pos.end.column.hash(state);
40        }
41        self.inner.value.hash(state);
42    }
43}
44
45/// Collect a map of comment pairs and their next non-comment nodes from the
46/// given AST.
47fn collect_comment_pairs(root: &Node) -> Option<HashMap<HashableMdxNode<'_>, Option<&Node>>> {
48    let mut comment_q = None::<VecDeque<_>>;
49    let mut pairs = None::<HashMap<_, _>>;
50
51    fn traverse<'node>(
52        node: &'node Node,
53        comment_q: &mut Option<VecDeque<&'node MdxFlowExpression>>,
54        pairs: &mut Option<HashMap<HashableMdxNode<'node>, Option<&'node Node>>>,
55    ) {
56        match node {
57            Node::MdxFlowExpression(expr) if expr.value.is_comment() => {
58                comment_q.get_or_insert_with(VecDeque::new).push_back(expr);
59            }
60            _ => {
61                while let Some(comment) = comment_q.as_mut().and_then(|p| p.pop_front()) {
62                    pairs
63                        .get_or_insert_with(HashMap::new)
64                        .insert(HashableMdxNode::new(comment), Some(node));
65                }
66            }
67        }
68
69        if let Some(children) = node.children() {
70            for child in children {
71                traverse(child, comment_q, pairs);
72            }
73        }
74    }
75
76    traverse(root, &mut comment_q, &mut pairs);
77
78    while let Some(comment) = comment_q.as_mut().and_then(|p| p.pop_front()) {
79        pairs
80            .get_or_insert_with(HashMap::new)
81            .insert(HashableMdxNode::new(comment), None);
82    }
83
84    pairs
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub(crate) enum RuleKey<'s> {
89    All,
90    Rule(Cow<'s, str>),
91}
92
93impl<'s> Hash for RuleKey<'s> {
94    fn hash<H: Hasher>(&self, state: &mut H) {
95        match self {
96            RuleKey::All => 0.hash(state),
97            RuleKey::Rule(rule) => rule.hash(state),
98        }
99    }
100}
101
102impl<'s> From<&'s str> for RuleKey<'s> {
103    fn from(rule: &'s str) -> Self {
104        RuleKey::Rule(Cow::Borrowed(rule))
105    }
106}
107
108impl<'s> From<String> for RuleKey<'s> {
109    fn from(rule: String) -> Self {
110        RuleKey::Rule(Cow::Owned(rule))
111    }
112}
113
114impl AsRef<str> for RuleKey<'_> {
115    fn as_ref(&self) -> &str {
116        match self {
117            RuleKey::All => "All rules",
118            RuleKey::Rule(rule) => rule,
119        }
120    }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124struct LintTimeConfigureAttr<'comment> {
125    rule_name: &'comment str,
126    attributes: Option<Cow<'comment, str>>,
127    next_line_only: bool,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131struct LintTimeConfigureInfo<'comment> {
132    attributes: LintTimeConfigureAttr<'comment>,
133    covered_range: MaybeEndedLineRange,
134}
135
136impl<'comment>
137    TryFrom<(
138        &str,
139        &'comment str,
140        Option<&'comment str>,
141        Option<&'comment str>,
142    )> for LintTimeConfigureAttr<'comment>
143{
144    type Error = ParseError;
145
146    fn try_from(
147        value: (
148            &str,
149            &'comment str,
150            Option<&'comment str>,
151            Option<&'comment str>,
152        ),
153    ) -> Result<Self, Self::Error> {
154        match value {
155            (_, "configure", Some(rule), Some(attributes)) => Ok(LintTimeConfigureAttr {
156                rule_name: rule,
157                attributes: Some(Cow::Borrowed(attributes)),
158                next_line_only: false,
159            }),
160            (_, "configure", Some(rule), None) => Ok(LintTimeConfigureAttr {
161                rule_name: rule,
162                attributes: None,
163                next_line_only: false,
164            }),
165            (_, "configure-next-line", Some(rule), Some(attributes)) => Ok(LintTimeConfigureAttr {
166                rule_name: rule,
167                attributes: Some(Cow::Borrowed(attributes)),
168                next_line_only: true,
169            }),
170            (_, "configure-next-line", Some(rule), None) => Ok(LintTimeConfigureAttr {
171                rule_name: rule,
172                attributes: None,
173                next_line_only: true,
174            }),
175            (orig, ..) => Err(ParseError::ConfigurationCommentMissingRule(
176                orig.to_string(),
177            )),
178        }
179    }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183enum RuleToggle {
184    EnableAll,
185    EnableRule { rule: String },
186    DisableAll { next_line_only: bool },
187    DisableRule { rule: String, next_line_only: bool },
188}
189
190impl From<(&str, Option<&str>)> for RuleToggle {
191    fn from(value: (&str, Option<&str>)) -> Self {
192        match value {
193            ("enable", Some(rule)) => RuleToggle::EnableRule {
194                rule: rule.to_string(),
195            },
196            ("enable", _) => RuleToggle::EnableAll,
197            ("disable", Some(rule)) => RuleToggle::DisableRule {
198                rule: rule.to_string(),
199                next_line_only: false,
200            },
201            ("disable", None) => RuleToggle::DisableAll {
202                next_line_only: false,
203            },
204            ("disable-next-line", Some(rule)) => RuleToggle::DisableRule {
205                rule: rule.to_string(),
206                next_line_only: true,
207            },
208            ("disable-next-line", None) => RuleToggle::DisableAll {
209                next_line_only: true,
210            },
211            _ => unreachable!("Only valid toggle arguments sent from call site (hardcoded)"),
212        }
213    }
214}
215
216trait NextLineOnly {
217    fn next_line_only(&self) -> bool;
218}
219
220impl NextLineOnly for RuleToggle {
221    fn next_line_only(&self) -> bool {
222        match self {
223            RuleToggle::DisableAll { next_line_only } => *next_line_only,
224            RuleToggle::DisableRule { next_line_only, .. } => *next_line_only,
225            _ => false,
226        }
227    }
228}
229
230impl NextLineOnly for LintTimeConfigureAttr<'_> {
231    fn next_line_only(&self) -> bool {
232        self.next_line_only
233    }
234}
235
236enum ConfigurationComment<'comment> {
237    Configure(LintTimeConfigureAttr<'comment>),
238    EnableDisable(RuleToggle),
239}
240
241static CONFIG_COMMENT_REGEX: OnceLock<Regex> = OnceLock::new();
242
243#[bon]
244impl<'comment> ConfigurationComment<'comment> {
245    fn parse(value: &'comment str) -> Option<Self> {
246        let comment_string = value.as_comment()?;
247
248        // supa-mdx-lint configure-next-line Rule001HeadingCase +Supabase +pgjwt
249        let regex = CONFIG_COMMENT_REGEX.get_or_init(||
250            Regex::new(r"^supa-mdx-lint-(enable|disable|disable-next-line|configure|configure-next-line)(?:\s+(\S+)(?:\s+(.+))?)?$").expect("Hardcoded regex should not fail")
251        );
252
253        if let Some(captures) = regex.captures(comment_string) {
254            if let Some(action) = captures.get(1) {
255                match action.as_str() {
256                    toggle @ ("enable" | "disable" | "disable-next-line") => {
257                        let rule_toggle =
258                            RuleToggle::from((toggle, captures.get(2).map(|m| m.as_str())));
259                        return Some(ConfigurationComment::EnableDisable(rule_toggle));
260                    }
261                    configuration @ ("configure" | "configure-next-line") => {
262                        return LintTimeConfigureAttr::try_from((
263                            comment_string,
264                            configuration,
265                            captures.get(2).map(|m| m.as_str()),
266                            captures.get(3).map(|m| m.as_str()),
267                        ))
268                        .ok()
269                        .map(ConfigurationComment::Configure);
270                    }
271                    _ => {}
272                }
273            }
274        }
275
276        None
277    }
278
279    #[builder]
280    fn get_covered_range(
281        curr: impl MaybePosition + VariantName,
282        next: Option<impl MaybePosition + VariantName>,
283        next_line_only: bool,
284        parsed: &ParseResult,
285    ) -> Result<MaybeEndedLineRange, ParseError> {
286        let Some(pos) = curr.position() else {
287            return Err(ParseError::MissingPosition(curr.variant_name()));
288        };
289
290        let start_offset = AdjustedOffset::from_unist(&pos.start, parsed.content_start_offset());
291        let start_line = AdjustedPoint::from_adjusted_offset(&start_offset, parsed.rope());
292
293        if !next_line_only {
294            return Ok(MaybeEndedLineRange::new(start_line.row, None));
295        }
296
297        let next = next
298            .map(|next| {
299                let Some(next_pos) = next.position() else {
300                    return Err(ParseError::MissingPosition(next.variant_name()));
301                };
302
303                let end_offset =
304                    AdjustedOffset::from_unist(&next_pos.start, parsed.content_start_offset());
305                let end_row =
306                    AdjustedPoint::from_adjusted_offset(&end_offset, parsed.rope()).row + 1;
307                let last_row = parsed.rope().line_len() - 1;
308                if end_row > last_row {
309                    Ok(None)
310                } else {
311                    Ok(Some(end_row))
312                }
313            })
314            .transpose()?
315            .flatten();
316
317        Ok(MaybeEndedLineRange::new(start_line.row, next))
318    }
319}
320
321#[allow(clippy::type_complexity)]
322#[derive(Debug, Default)]
323pub(crate) struct ConfigurationCommentCollection<'comment>(
324    Vec<
325        Result<
326            Either<LintTimeConfigureInfo<'comment>, (RuleToggle, MaybeEndedLineRange)>,
327            ParseError,
328        >,
329    >,
330);
331
332impl<'ast> ConfigurationCommentCollection<'ast> {
333    pub(crate) fn from_parse_result(parsed: &'ast ParseResult) -> Self {
334        let ast = parsed.ast();
335        let Some(comment_pairs) = collect_comment_pairs(ast) else {
336            return Self::default();
337        };
338        let comment_pairs = comment_pairs
339            .into_iter()
340            .filter_map(|(comment, next_node)| {
341                if let Some(config_comment) = ConfigurationComment::parse(&comment.inner.value) {
342                    match config_comment {
343                        ConfigurationComment::Configure(info) => {
344                            match ConfigurationComment::get_covered_range()
345                                .curr(comment.inner)
346                                .maybe_next(next_node)
347                                .next_line_only(info.next_line_only())
348                                .parsed(parsed)
349                                .call()
350                            {
351                                Ok(range) => Some(Ok(Either::Left(LintTimeConfigureInfo {
352                                    attributes: info,
353                                    covered_range: range,
354                                }))),
355                                Err(err) => Some(Err(err)),
356                            }
357                        }
358                        ConfigurationComment::EnableDisable(info) => {
359                            match ConfigurationComment::get_covered_range()
360                                .curr(comment.inner)
361                                .maybe_next(next_node)
362                                .next_line_only(info.next_line_only())
363                                .parsed(parsed)
364                                .call()
365                            {
366                                Ok(range) => Some(Ok(Either::Right((info, range)))),
367                                Err(err) => Some(Err(err)),
368                            }
369                        }
370                    }
371                } else {
372                    None
373                }
374            })
375            .collect();
376        Self(comment_pairs)
377    }
378
379    pub(crate) fn into_parts(
380        self,
381    ) -> ResultBoth<(LintTimeRuleConfigs<'ast>, LintDisables<'ast>), MultiError> {
382        let mut configs = LintTimeRuleConfigs::default();
383        let mut disables_builder = LintDisablesBuilder::default();
384        let mut errors = None::<MultiError>;
385        for res in self.0.into_iter() {
386            match res {
387                Ok(Either::Left(info)) => {
388                    let attributes = info.attributes.attributes.clone();
389                    configs
390                        .entry(info.attributes.rule_name.into())
391                        .or_default()
392                        .push((
393                            attributes.unwrap_or_default().into_owned(),
394                            info.covered_range.clone(),
395                        ));
396                }
397                Ok(Either::Right((info, range))) => match info {
398                    RuleToggle::EnableAll => {
399                        disables_builder.add_toggle(RuleKey::All, Switch::On, range.clone())
400                    }
401                    RuleToggle::EnableRule { rule } => {
402                        disables_builder.add_toggle(rule.into(), Switch::On, range.clone())
403                    }
404                    RuleToggle::DisableAll { .. } => {
405                        disables_builder.add_toggle(RuleKey::All, Switch::Off, range.clone())
406                    }
407                    RuleToggle::DisableRule { rule, .. } => {
408                        disables_builder.add_toggle(rule.into(), Switch::Off, range.clone())
409                    }
410                },
411                Err(err) => {
412                    errors
413                        .get_or_insert_with(MultiError::default)
414                        .add_err(err.into());
415                }
416            }
417        }
418
419        let (disables, build_err) = disables_builder.build().split();
420        if let Some(build_err) = build_err {
421            errors
422                .get_or_insert_with(MultiError::default)
423                .add_err(Box::new(build_err));
424        }
425
426        ResultBoth::new((configs, disables), errors)
427    }
428}
429
430#[derive(Debug, Default)]
431pub(crate) struct LintTimeRuleConfigs<'key>(
432    HashMap<RuleKey<'key>, Vec<(String, MaybeEndedLineRange)>>,
433);
434
435impl<'key> std::ops::Deref for LintTimeRuleConfigs<'key> {
436    type Target = HashMap<RuleKey<'key>, Vec<(String, MaybeEndedLineRange)>>;
437
438    fn deref(&self) -> &Self::Target {
439        &self.0
440    }
441}
442
443impl std::ops::DerefMut for LintTimeRuleConfigs<'_> {
444    fn deref_mut(&mut self) -> &mut Self::Target {
445        &mut self.0
446    }
447}
448
449#[derive(Debug)]
450enum Switch {
451    On,
452    Off,
453}
454
455#[derive(Debug, Default)]
456struct LintDisablesBuilder<'key>(HashMap<RuleKey<'key>, Vec<(Switch, MaybeEndedLineRange)>>);
457
458#[derive(Debug, Default)]
459pub struct LintDisables<'key>(HashMap<RuleKey<'key>, Vec<MaybeEndedLineRange>>);
460
461#[derive(Debug)]
462enum MergeRangesResult {
463    Merged,
464    NotMerged((Switch, MaybeEndedLineRange)),
465}
466
467/// Merges two disable directives if they are a pair (disable, enable).
468fn maybe_merge_ranges(
469    rule: &str,
470    last: &mut MaybeEndedLineRange,
471    curr: (Switch, MaybeEndedLineRange),
472) -> Result<MergeRangesResult, ParseError> {
473    match curr.0 {
474        Switch::On => {
475            if last.is_open_ended() {
476                last.end = Some(curr.1.start);
477                Ok(MergeRangesResult::Merged)
478            } else {
479                Err(ParseError::UnmatchedConfigurationPair(
480                    format!("{rule} enabled without a matching disable comment"),
481                    curr.1.start,
482                ))
483            }
484        }
485        Switch::Off => {
486            if last.overlaps_strict(&curr.1) {
487                last.end = last.end.max(curr.1.end);
488                Err(ParseError::UnmatchedConfigurationPair(format!("{rule} disabled twice in succession for overlapping ranges. This is probably not what you want and can cause the effective range to be different from expected."), curr.1.start))
489            } else {
490                Ok(MergeRangesResult::NotMerged(curr))
491            }
492        }
493    }
494}
495
496impl<'key> LintDisablesBuilder<'key> {
497    fn add_toggle(&mut self, rule_key: RuleKey<'key>, switch: Switch, range: MaybeEndedLineRange) {
498        self.0.entry(rule_key).or_default().push((switch, range));
499    }
500
501    fn build(self) -> ResultBoth<LintDisables<'key>, MultiError> {
502        let mut disables = HashMap::new();
503        let mut errors = None::<MultiError>;
504
505        for (rule_key, mut toggles) in self.0 {
506            toggles.sort_by_key(|(_, range)| range.clone());
507
508            let mut disabled_ranges = Vec::<(Switch, MaybeEndedLineRange)>::new();
509            for toggle in toggles {
510                match disabled_ranges.last_mut() {
511                    Some(last) => {
512                        match maybe_merge_ranges(rule_key.as_ref(), &mut last.1, toggle) {
513                            Ok(MergeRangesResult::Merged) => {}
514                            Ok(MergeRangesResult::NotMerged(toggle)) => {
515                                disabled_ranges.push(toggle)
516                            }
517                            Err(err) => {
518                                errors
519                                    .get_or_insert_with(MultiError::default)
520                                    .add_err(Box::new(err));
521                            }
522                        }
523                    }
524                    None if matches!(toggle.0, Switch::Off) => {
525                        disabled_ranges.push(toggle);
526                    }
527                    None if matches!(toggle.0, Switch::On) => {
528                        errors
529                            .get_or_insert_with(MultiError::default)
530                            .add_err(Box::new(ParseError::UnmatchedConfigurationPair(
531                                format!(
532                                "{} enabled with corresponding disable statement. This is a no-op",
533                                rule_key.as_ref()
534                            ),
535                                toggle.1.start,
536                            )));
537                    }
538                    _ => unreachable!(
539                        "Compiler does not seem to know that all the toggle variations are covered"
540                    ),
541                }
542            }
543            disables.insert(
544                rule_key,
545                disabled_ranges
546                    .into_iter()
547                    .map(|(_, range)| range)
548                    .collect(),
549            );
550        }
551
552        ResultBoth::new(LintDisables(disables), errors)
553    }
554}
555
556impl<'key> LintDisables<'key> {
557    pub(crate) fn disabled_for_location(
558        &self,
559        rule_name: &str,
560        location: &DenormalizedLocation,
561        ctx: &Context,
562    ) -> bool {
563        let all_key = RuleKey::All;
564        let specific_key = RuleKey::from(rule_name);
565
566        if let Some(disabled_ranges) = self.0.get(&all_key) {
567            if disabled_ranges
568                .iter()
569                .any(|range| range.overlaps_lines(&location.offset_range, ctx.rope()))
570            {
571                return true;
572            }
573        } else if let Some(disabled_ranges) = self.0.get(&specific_key) {
574            if disabled_ranges
575                .iter()
576                .any(|range| range.overlaps_lines(&location.offset_range, ctx.rope()))
577            {
578                return true;
579            }
580        }
581
582        false
583    }
584}
585
586#[cfg(test)]
587mod test {
588
589    use markdown::mdast::Paragraph;
590
591    use crate::parse;
592
593    use super::*;
594
595    #[test]
596    fn test_collect_comment_pairs() {
597        let markdown = r#"
598{/* Comment 1 */}
599{/* Comment 2 */}
600Paragraph 1
601
602A list:
603- Item 1
604  {/* Comment 3 */}
605- Item 2
606"#;
607
608        let parse_result = parse(markdown).unwrap();
609        let mut comment_pairs = collect_comment_pairs(parse_result.ast())
610            .unwrap()
611            .into_iter()
612            .collect::<Vec<_>>();
613        comment_pairs.sort_by_key(|(comment, _)| &comment.inner.value);
614
615        let first = comment_pairs.first().unwrap();
616        assert_eq!(first.0.inner.value, "/* Comment 1 */");
617        match first.1 {
618            Some(Node::Paragraph(Paragraph { children, .. })) => match children.get(0).unwrap() {
619                Node::Text(text) => {
620                    assert_eq!(text.value, "Paragraph 1");
621                }
622                _ => {
623                    panic!("Expected a text node");
624                }
625            },
626            _ => {
627                panic!("Expected a paragraph");
628            }
629        }
630
631        let second = comment_pairs.get(1).unwrap();
632        assert_eq!(second.0.inner.value, "/* Comment 2 */");
633        match second.1 {
634            Some(Node::Paragraph(Paragraph { children, .. })) => match children.get(0).unwrap() {
635                Node::Text(text) => {
636                    assert_eq!(text.value, "Paragraph 1");
637                }
638                _ => {
639                    panic!("Expected a text node");
640                }
641            },
642            _ => {
643                panic!("Expected a paragraph");
644            }
645        }
646
647        let third = comment_pairs.get(2).unwrap();
648        assert_eq!(third.0.inner.value, "/* Comment 3 */");
649        match third.1 {
650            Some(Node::ListItem(list_item)) => match list_item.children.get(0).unwrap() {
651                Node::Paragraph(Paragraph { children, .. }) => match children.get(0).unwrap() {
652                    Node::Text(text) => {
653                        assert_eq!(text.value, "Item 2");
654                    }
655                    _ => {
656                        panic!("Expected a text node");
657                    }
658                },
659                _ => {
660                    panic!("Expected a paragraph");
661                }
662            },
663            _ => {
664                panic!("Expected a list item");
665            }
666        }
667    }
668
669    #[test]
670    fn rule_toggle_parse_enable_all() {
671        let value = "/* supa-mdx-lint-enable */";
672        assert!(matches!(
673            ConfigurationComment::parse(value),
674            Some(ConfigurationComment::EnableDisable(RuleToggle::EnableAll))
675        ));
676    }
677
678    #[test]
679    fn rule_toggle_parse_enable_specific_rule() {
680        let value = "/* supa-mdx-lint-enable specific-rule */";
681        assert!(matches!(
682            ConfigurationComment::parse(value),
683            Some(ConfigurationComment::EnableDisable(RuleToggle::EnableRule { rule }))
684                if rule == "specific-rule"
685        ));
686    }
687
688    #[test]
689    fn rule_toggle_parse_disable_all() {
690        let value = "/* supa-mdx-lint-disable */";
691        assert!(matches!(
692            ConfigurationComment::parse(value),
693            Some(ConfigurationComment::EnableDisable(RuleToggle::DisableAll { next_line_only }))
694                if !next_line_only
695        ));
696    }
697
698    #[test]
699    fn rule_toggle_parse_disable_specific_rule() {
700        let value = "/* supa-mdx-lint-disable specific-rule */";
701        assert!(matches!(
702            ConfigurationComment::parse(value),
703            Some(ConfigurationComment::EnableDisable(RuleToggle::DisableRule { rule, next_line_only }))
704            if rule == "specific-rule" && !next_line_only
705        ));
706    }
707
708    #[test]
709    fn rule_toggle_parse_disable_next_line_all() {
710        let value = "/* supa-mdx-lint-disable-next-line */";
711        assert!(matches!(
712            ConfigurationComment::parse(value),
713            Some(ConfigurationComment::EnableDisable(RuleToggle::DisableAll { next_line_only }))
714            if next_line_only
715        ));
716    }
717
718    #[test]
719    fn rule_toggle_parse_disable_next_line_specific_rule() {
720        let value = "/* supa-mdx-lint-disable-next-line specific-rule */";
721        assert!(matches!(
722            ConfigurationComment::parse(value),
723            Some(ConfigurationComment::EnableDisable(RuleToggle::DisableRule { rule, next_line_only }))
724            if rule == "specific-rule" && next_line_only
725        ));
726    }
727
728    #[test]
729    fn rule_toggle_parse_invalid_format() {
730        let value = "supa-mdx-lint-enable";
731        assert!(ConfigurationComment::parse(value).is_none());
732    }
733
734    #[test]
735    fn rule_toggle_parse_invalid_command() {
736        let value = "/* supa-mdx-lint-invalid */";
737        assert!(ConfigurationComment::parse(value).is_none());
738    }
739
740    #[test]
741    fn rule_toggle_parse_ignores_whitespace() {
742        let value = "     /*     supa-mdx-lint-enable  rule-name  */";
743        assert!(matches!(
744            ConfigurationComment::parse(value),
745            Some(ConfigurationComment::EnableDisable(RuleToggle::EnableRule { rule }))
746            if rule == "rule-name"
747        ));
748    }
749
750    #[test]
751    fn test_collect_lint_disables_basic() {
752        let input = r#"{/* supa-mdx-lint-disable foo */}
753Some content
754{/* supa-mdx-lint-enable foo */}"#;
755
756        let parse_result = parse(input).unwrap();
757        let (_, disables) = ConfigurationCommentCollection::from_parse_result(&parse_result)
758            .into_parts()
759            .unwrap();
760
761        assert_eq!(disables.0.len(), 1);
762        assert_eq!(disables.0[&"foo".into()][0].start, 0);
763        assert_eq!(disables.0[&"foo".into()][0].end, Some(2));
764    }
765
766    #[test]
767    fn test_collect_lint_disables_multiple_rules() {
768        let input = r#"{/* supa-mdx-lint-disable foo */}
769Content
770{/* supa-mdx-lint-disable bar */}
771More content
772{/* supa-mdx-lint-enable foo */}
773{/* supa-mdx-lint-enable bar */}"#;
774
775        let parse_result = parse(input).unwrap();
776        let (_, disables) = ConfigurationCommentCollection::from_parse_result(&parse_result)
777            .into_parts()
778            .unwrap();
779
780        assert_eq!(disables.0.len(), 2);
781        assert_eq!(disables.0[&"bar".into()][0].start, 2);
782        assert_eq!(disables.0[&"bar".into()][0].end, Some(5));
783    }
784
785    #[test]
786    fn test_collect_lint_disables_next_line() {
787        let input = r#"{/* supa-mdx-lint-disable-next-line foo */}
788This line is ignored
789This line is not ignored"#;
790
791        let parse_result = parse(input).unwrap();
792        let (_, disables) = ConfigurationCommentCollection::from_parse_result(&parse_result)
793            .into_parts()
794            .unwrap();
795
796        assert_eq!(disables.0.len(), 1);
797        assert_eq!(disables.0[&"foo".into()][0].end, Some(2));
798    }
799
800    #[test]
801    fn test_collect_lint_disables_disable_all() {
802        let input = r#"{/* supa-mdx-lint-disable */}
803Everything here is ignored
804Still ignored
805{/* supa-mdx-lint-enable */}"#;
806
807        let parse_result = parse(input).unwrap();
808        let (_, disables) = ConfigurationCommentCollection::from_parse_result(&parse_result)
809            .into_parts()
810            .unwrap();
811
812        assert_eq!(disables.0.len(), 1);
813        assert_eq!(disables.0[&RuleKey::All][0].start, 0);
814        assert_eq!(disables.0[&RuleKey::All][0].end, Some(3));
815    }
816
817    #[test]
818    fn test_collect_lint_never_reenabled() {
819        let input = r#"{/* supa-mdx-lint-disable foo */}
820Never reenabled"#;
821
822        let parse_result = parse(input).unwrap();
823        let (_, disables) = ConfigurationCommentCollection::from_parse_result(&parse_result)
824            .into_parts()
825            .unwrap();
826
827        assert_eq!(disables.0.len(), 1);
828    }
829
830    #[test]
831    fn test_collect_lint_disables_invalid_enable() {
832        let input = r#"{/* supa-mdx-lint-enable foo */}
833This should error because there was no disable"#;
834
835        let parse_result = parse(input).unwrap();
836        let result = ConfigurationCommentCollection::from_parse_result(&parse_result).into_parts();
837
838        assert!(result.has_err());
839    }
840
841    #[test]
842    fn test_collect_lint_disables_skip_blank_lines() {
843        let input = r#"{/* supa-mdx-lint-disable-next-line foo */}
844
845This line is ignored
846This line is not ignored"#;
847
848        let parse_result = parse(input).unwrap();
849        let (_, disables) = ConfigurationCommentCollection::from_parse_result(&parse_result)
850            .into_parts()
851            .unwrap();
852
853        assert_eq!(disables.0.len(), 1);
854        assert_eq!(disables.0[&"foo".into()][0].end, Some(3));
855    }
856
857    #[test]
858    fn test_collect_lint_disables_skip_intervening_comments() {
859        let input = r#"{/* supa-mdx-lint-disable-next-line foo */}
860
861{/* some other comment */}
862{/* supa-mdx-lint-disable-next-line bar */}
863
864This line is ignored by both foo and bar
865This line is not ignored
866"#;
867
868        let parse_result = parse(input).unwrap();
869        let (_, disables) = ConfigurationCommentCollection::from_parse_result(&parse_result)
870            .into_parts()
871            .unwrap();
872
873        assert_eq!(disables.0.len(), 2);
874        assert_eq!(disables.0[&"foo".into()][0].start, 0);
875        assert_eq!(disables.0[&"foo".into()][0].end, Some(6));
876        assert_eq!(disables.0[&"bar".into()][0].start, 3);
877        assert_eq!(disables.0[&"bar".into()][0].end, Some(6));
878    }
879
880    #[test]
881    fn test_collect_lint_disables_with_frontmatter() {
882        let input = r#"---
883title: Some frontmatter
884description: Testing with frontmatter
885---
886
887{/* supa-mdx-lint-disable-next-line foo */}
888This line should be ignored by foo
889Regular content
890
891{/* supa-mdx-lint-disable bar */}
892These lines should be ignored by bar
893More content
894{/* supa-mdx-lint-enable bar */}
895
896This line should not be ignored
897"#;
898
899        let parse_result = parse(input).unwrap();
900        let (_, disables) = ConfigurationCommentCollection::from_parse_result(&parse_result)
901            .into_parts()
902            .unwrap();
903
904        assert_eq!(disables.0.len(), 2);
905
906        // Check foo rule
907        assert_eq!(disables.0[&"foo".into()].len(), 1);
908        assert_eq!(disables.0[&"foo".into()][0].start, 5);
909        assert_eq!(disables.0[&"foo".into()][0].end, Some(7));
910
911        // Check bar rule
912        assert_eq!(disables.0[&"bar".into()].len(), 1);
913        assert_eq!(disables.0[&"bar".into()][0].start, 9);
914        assert_eq!(disables.0[&"bar".into()][0].end, Some(12));
915    }
916}