Skip to main content

plexus_devtools/
lint.rs

1use std::collections::{BTreeSet, VecDeque};
2
3use serde::Serialize;
4
5use plexus_serde::{Op, Plan};
6
7use crate::explain::{op_has_graph_ref, op_inputs};
8
9#[derive(Debug, Clone, Serialize)]
10pub struct LintIssue {
11    pub code: &'static str,
12    pub severity: &'static str,
13    pub message: String,
14    pub op_index: Option<usize>,
15}
16
17pub fn lint_plan(plan: &Plan) -> Vec<LintIssue> {
18    let mut issues = Vec::new();
19
20    issues.extend(check_limit_without_sort(plan));
21    issues.extend(check_unreachable_ops(plan));
22    issues.extend(check_root_not_return(plan));
23    issues.extend(check_multi_graph_mix(plan));
24
25    issues
26}
27
28fn check_limit_without_sort(plan: &Plan) -> Vec<LintIssue> {
29    let mut issues = Vec::new();
30    for (idx, op) in plan.ops.iter().enumerate() {
31        let Op::Limit { input, .. } = op else {
32            continue;
33        };
34        let input_idx = *input as usize;
35        let input_op = plan.ops.get(input_idx);
36        if !matches!(input_op, Some(Op::Sort { .. })) {
37            issues.push(LintIssue {
38                code: "LIMIT_WITHOUT_SORT",
39                severity: "warning",
40                message: "Limit input is not Sort; result ordering may be nondeterministic"
41                    .to_string(),
42                op_index: Some(idx),
43            });
44        }
45    }
46    issues
47}
48
49fn check_unreachable_ops(plan: &Plan) -> Vec<LintIssue> {
50    if plan.ops.is_empty() {
51        return Vec::new();
52    }
53
54    let root = plan.root_op as usize;
55    if root >= plan.ops.len() {
56        return vec![LintIssue {
57            code: "INVALID_ROOT",
58            severity: "error",
59            message: format!(
60                "root_op {} is outside ops length {}",
61                plan.root_op,
62                plan.ops.len()
63            ),
64            op_index: None,
65        }];
66    }
67
68    let mut seen = BTreeSet::new();
69    let mut queue = VecDeque::from([root]);
70    while let Some(idx) = queue.pop_front() {
71        if !seen.insert(idx) {
72            continue;
73        }
74        if let Some(op) = plan.ops.get(idx) {
75            for input in op_inputs(op) {
76                queue.push_back(input as usize);
77            }
78        }
79    }
80
81    let mut issues = Vec::new();
82    for idx in 0..plan.ops.len() {
83        if !seen.contains(&idx) {
84            issues.push(LintIssue {
85                code: "UNREACHABLE_OP",
86                severity: "warning",
87                message: "Op is not reachable from root_op".to_string(),
88                op_index: Some(idx),
89            });
90        }
91    }
92    issues
93}
94
95fn check_root_not_return(plan: &Plan) -> Vec<LintIssue> {
96    let root = plan.root_op as usize;
97    let Some(op) = plan.ops.get(root) else {
98        return Vec::new();
99    };
100    if matches!(op, Op::Return { .. }) {
101        return Vec::new();
102    }
103
104    vec![LintIssue {
105        code: "ROOT_NOT_RETURN",
106        severity: "warning",
107        message:
108            "root_op is not Return; external consumers may expect an explicit Return terminator"
109                .to_string(),
110        op_index: Some(root),
111    }]
112}
113
114fn check_multi_graph_mix(plan: &Plan) -> Vec<LintIssue> {
115    let mut refs = BTreeSet::new();
116    for op in &plan.ops {
117        if !op_has_graph_ref(op) {
118            continue;
119        }
120        match op {
121            Op::ScanNodes { graph_ref, .. }
122            | Op::Expand { graph_ref, .. }
123            | Op::OptionalExpand { graph_ref, .. }
124            | Op::ExpandVarLen { graph_ref, .. } => {
125                if let Some(r) = graph_ref.as_ref() {
126                    refs.insert(r.trim().to_string());
127                }
128            }
129            _ => {}
130        }
131    }
132
133    if refs.len() <= 1 {
134        return Vec::new();
135    }
136
137    vec![LintIssue {
138        code: "MULTI_GRAPH_REFS",
139        severity: "warning",
140        message: format!(
141            "Plan contains multiple graph_ref values: {}",
142            refs.into_iter().collect::<Vec<_>>().join(", ")
143        ),
144        op_index: None,
145    }]
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use plexus_serde::{current_plan_version, ColDef, ColKind, Op, Plan};
152
153    #[test]
154    fn emits_limit_without_sort_warning() {
155        let plan = Plan {
156            version: current_plan_version("test"),
157            ops: vec![
158                Op::ScanNodes {
159                    labels: Vec::new(),
160                    schema: vec![ColDef {
161                        name: "n".to_string(),
162                        kind: ColKind::Node,
163                        logical_type: plexus_serde::LogicalType::Unknown,
164                    }],
165                    must_labels: Vec::new(),
166                    forbidden_labels: Vec::new(),
167                    est_rows: 10,
168                    selectivity: 1.0,
169                    graph_ref: None,
170                },
171                Op::Limit {
172                    input: 0,
173                    count: 5,
174                    skip: 0,
175                    cursor: None,
176                    emit_cursor: false,
177                },
178            ],
179            root_op: 1,
180        };
181
182        let issues = lint_plan(&plan);
183        assert!(issues.iter().any(|i| i.code == "LIMIT_WITHOUT_SORT"));
184        assert!(issues.iter().any(|i| i.code == "ROOT_NOT_RETURN"));
185    }
186}