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}