crates/kernel/ents-query/src/parse.rs
parse.rshistorycomment 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 | |
| 7 | use crate::ast::{Query, SetOp, StatusFilter}; |
| 8 | use crate::error::ParseError; |
| 9 | use crate::pattern::RefPattern; |
| 10 | use 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. |
| 15 | const 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) |
| 23 | pub(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 | |
| 45 | struct Parser<'a> { |
| 46 | input: &'a str, |
| 47 | pos: usize, |
| 48 | } |
| 49 | |
| 50 | impl 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. |
| 184 | fn 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). |
| 213 | fn 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`). |
| 219 | fn 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 | } |