git-ents.gitmain
⌘K
foforge
parse.rs236 lines · 8.1 KB · rusthistorycomment on this file
1//! The `CommitQuery` parser (`query.grammar`), including the
2//! bare-glob compatibility rule (`query.rev-pattern-compat`) and every
3//! write-time rejection `effect.validation` cites: unknown atoms
4//! (`query.no-extensions`), `refs/meta/*` inside `rev()` (`query.rev`),
5//! and effect-written namespaces inside `meta()` (`query.meta`).
6
7use crate::ast::{Query, SetOp, StatusFilter};
8use crate::error::ParseError;
9use crate::pattern::RefPattern;
10use crate::rev::RevExpr;
11
12/// The namespaces `meta()` must never be able to match (`query.meta`):
13/// recorded results are reachable only through `results(...)`, and the
14/// fanout index is not addressable by any query atom at all.
15const EFFECT_WRITTEN: [&str; 2] = ["refs/meta/results/", "refs/meta/index/"];
16
17/// Parse a `CommitQuery`.
18///
19/// A bare ref glob is accepted wherever a query is expected, meaning
20/// exactly `rev(<glob>)` (`query.rev-pattern-compat`) — so a
21/// `RefPattern` predating `CommitQuery` keeps denoting the same set.
22// @relation(query.grammar, query.rev-pattern-compat, scope=function)
23pub(crate) fn parse(input: &str) -> Result<Query, ParseError> {
24 let mut parser = Parser { input, pos: 0 };
25 match parser.parse_query_complete() {
26 Ok(query) => Ok(query),
27 Err(err) => {
28 // The degenerate form: the whole (trimmed) input is one
29 // glob/refname token with none of the grammar's own
30 // structure in it. Its validation errors (a refs/meta/*
31 // glob, above all) are the ones worth surfacing.
32 let token = input.trim();
33 if token.is_empty()
34 || token
35 .bytes()
36 .any(|b| b.is_ascii_whitespace() || b"()|&,".contains(&b))
37 {
38 return Err(err);
39 }
40 RevExpr::parse(token).map(Query::Rev)
41 }
42 }
43}
44
45struct Parser<'a> {
46 input: &'a str,
47 pos: usize,
48}
49
50impl Parser<'_> {
51 fn parse_query_complete(&mut self) -> Result<Query, ParseError> {
52 let query = self.parse_query()?;
53 self.skip_ws();
54 if self.pos < self.input.len() {
55 return Err(ParseError::Trailing {
56 rest: self.rest().to_owned(),
57 });
58 }
59 Ok(query)
60 }
61
62 /// `query ::= term (("|" | "&" | "-") term)*` — left-associative,
63 /// one precedence level (`query.grammar`).
64 fn parse_query(&mut self) -> Result<Query, ParseError> {
65 let mut lhs = self.parse_term()?;
66 loop {
67 self.skip_ws();
68 let op = match self.peek() {
69 Some(b'|') => SetOp::Union,
70 Some(b'&') => SetOp::Intersect,
71 Some(b'-') => SetOp::Difference,
72 _ => return Ok(lhs),
73 };
74 self.pos = self.pos.saturating_add(1);
75 let rhs = self.parse_term()?;
76 lhs = Query::Op {
77 op,
78 lhs: Box::new(lhs),
79 rhs: Box::new(rhs),
80 };
81 }
82 }
83
84 fn parse_term(&mut self) -> Result<Query, ParseError> {
85 self.skip_ws();
86 match self.peek() {
87 None => Err(ParseError::UnexpectedEnd),
88 Some(b'(') => {
89 let opened_at = self.pos;
90 self.pos = self.pos.saturating_add(1);
91 let inner = self.parse_query()?;
92 self.skip_ws();
93 if self.peek() == Some(b')') {
94 self.pos = self.pos.saturating_add(1);
95 Ok(inner)
96 } else {
97 Err(ParseError::Unbalanced { at: opened_at })
98 }
99 }
100 Some(_) => self.parse_atom(),
101 }
102 }
103
104 fn parse_atom(&mut self) -> Result<Query, ParseError> {
105 let name = self.take_while(|b| b.is_ascii_alphanumeric() || b == b'_');
106 if name.is_empty() {
107 return Err(ParseError::Expected {
108 expected: "an atom (rev, results, or meta)",
109 at: self.pos,
110 });
111 }
112 let name = name.to_owned();
113 self.skip_ws();
114 if self.peek() != Some(b'(') {
115 return Err(ParseError::Expected {
116 expected: "'(' after the atom name",
117 at: self.pos,
118 });
119 }
120 let opened_at = self.pos;
121 self.pos = self.pos.saturating_add(1);
122 let args = self.take_balanced(opened_at)?.to_owned();
123 match name.as_str() {
124 "rev" => Ok(Query::Rev(RevExpr::parse(&args)?)),
125 "results" => parse_results(&args, self.pos),
126 "meta" => parse_meta(&args),
127 // The closed atom set: a content, time, or external-event
128 // term is an unknown atom, permanently
129 // (`query.no-extensions`).
130 _ => Err(ParseError::UnknownAtom { name }),
131 }
132 }
133
134 /// Consume up to the `)` matching the `(` at `opened_at` (exclusive)
135 /// and step past it, returning the enclosed text.
136 fn take_balanced(&mut self, opened_at: usize) -> Result<&str, ParseError> {
137 let start = self.pos;
138 let mut depth = 1usize;
139 while let Some(b) = self.peek() {
140 match b {
141 b'(' => depth = depth.saturating_add(1),
142 b')' => {
143 depth = depth.saturating_sub(1);
144 if depth == 0 {
145 let inner = self.input.get(start..self.pos).unwrap_or_default();
146 self.pos = self.pos.saturating_add(1);
147 return Ok(inner);
148 }
149 }
150 _ => {}
151 }
152 self.pos = self.pos.saturating_add(1);
153 }
154 Err(ParseError::Unbalanced { at: opened_at })
155 }
156
157 fn peek(&self) -> Option<u8> {
158 self.input.as_bytes().get(self.pos).copied()
159 }
160
161 fn skip_ws(&mut self) {
162 while self.peek().is_some_and(|b| b.is_ascii_whitespace()) {
163 self.pos = self.pos.saturating_add(1);
164 }
165 }
166
167 fn take_while(&mut self, keep: impl Fn(u8) -> bool) -> &str {
168 let start = self.pos;
169 while self.peek().is_some_and(&keep) {
170 self.pos = self.pos.saturating_add(1);
171 }
172 self.input.get(start..self.pos).unwrap_or_default()
173 }
174
175 fn rest(&self) -> &str {
176 self.input.get(self.pos..).unwrap_or_default()
177 }
178}
179
180/// `results(effect, status)` — two arguments, an effect name that is a
181/// valid single ref-path segment (`effect.definition`) and never the
182/// reserved `self` (`query.workset`), and a status from the closed
183/// taxonomy.
184fn parse_results(args: &str, at: usize) -> Result<Query, ParseError> {
185 let Some((effect, status)) = args.split_once(',') else {
186 return Err(ParseError::Expected {
187 expected: "results(effect, status)",
188 at,
189 });
190 };
191 let effect = effect.trim();
192 let status = status.trim();
193 if effect == "self" {
194 return Err(ParseError::SelfKeyword);
195 }
196 if effect.is_empty() || effect.contains('/') || !valid_ref_segment(effect) {
197 return Err(ParseError::BadEffectName {
198 got: effect.to_owned(),
199 });
200 }
201 let status = StatusFilter::parse(status).ok_or_else(|| ParseError::BadStatus {
202 got: status.to_owned(),
203 })?;
204 Ok(Query::Results {
205 effect: effect.to_owned(),
206 status,
207 })
208}
209
210/// A single segment is valid exactly when gitoxide accepts it inside a
211/// full refname — no parallel validation rules (`arch` sibling rule:
212/// gitoxide types are the primitives).
213fn valid_ref_segment(segment: &str) -> bool {
214 gix::refs::FullName::try_from(format!("refs/meta/results/{segment}/x")).is_ok()
215}
216
217/// `meta(glob)` — must stay under `refs/meta/*` and must not be able to
218/// match an effect-written namespace (`query.meta`).
219fn parse_meta(args: &str) -> Result<Query, ParseError> {
220 let glob = args.trim();
221 let pattern = RefPattern::new(glob)?;
222 if !glob.starts_with("refs/meta/") {
223 return Err(ParseError::MetaGlobOutside {
224 glob: glob.to_owned(),
225 });
226 }
227 for namespace in EFFECT_WRITTEN {
228 if pattern.may_match_with_prefix(namespace) {
229 return Err(ParseError::MetaGlobEffectWritten {
230 glob: glob.to_owned(),
231 namespace,
232 });
233 }
234 }
235 Ok(Query::Meta(pattern))
236}