Skip to content

Commit c05a1c7

Browse files
committed
Parse r? commands.
1 parent 57d45e4 commit c05a1c7

File tree

5 files changed

+176
-45
lines changed

5 files changed

+176
-45
lines changed

Cargo.lock

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

parser/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ edition = "2021"
77
[dependencies]
88
pulldown-cmark = "0.7.0"
99
log = "0.4"
10+
regex = "1.6.0"

parser/src/command.rs

Lines changed: 110 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::error::Error;
22
use crate::ignore_block::IgnoreBlocks;
3-
use crate::token::{Token, Tokenizer};
3+
use crate::token::Tokenizer;
4+
use regex::Regex;
45

56
pub mod assign;
67
pub mod close;
@@ -13,10 +14,6 @@ pub mod relabel;
1314
pub mod second;
1415
pub mod shortcut;
1516

16-
pub fn find_command_start(input: &str, bot: &str) -> Option<usize> {
17-
input.to_ascii_lowercase().find(&format!("@{}", bot))
18-
}
19-
2017
#[derive(Debug, PartialEq)]
2118
pub enum Command<'a> {
2219
Relabel(Result<relabel::RelabelCommand, Error<'a>>),
@@ -36,9 +33,9 @@ pub struct Input<'a> {
3633
all: &'a str,
3734
parsed: usize,
3835
ignore: IgnoreBlocks,
39-
40-
// A list of possible bot names.
41-
bot: Vec<&'a str>,
36+
/// A pattern for finding the start of a command based on the name of the
37+
/// configured bots.
38+
bot_re: Regex,
4239
}
4340

4441
fn parse_single_command<'a, T, F, M>(
@@ -63,25 +60,22 @@ where
6360

6461
impl<'a> Input<'a> {
6562
pub fn new(input: &'a str, bot: Vec<&'a str>) -> Input<'a> {
63+
let bots: Vec<_> = bot.iter().map(|bot| format!(r"(?:@{bot}\b)")).collect();
64+
let bot_re = Regex::new(&format!(
65+
r#"(?i)(?P<review>\br\?)|{bots}"#,
66+
bots = bots.join("|")
67+
))
68+
.unwrap();
6669
Input {
6770
all: input,
6871
parsed: 0,
6972
ignore: IgnoreBlocks::new(input),
70-
bot,
73+
bot_re,
7174
}
7275
}
7376

7477
fn parse_command(&mut self) -> Option<Command<'a>> {
75-
let mut tok = Tokenizer::new(&self.all[self.parsed..]);
76-
let name_length = if let Ok(Some(Token::Word(bot_name))) = tok.next_token() {
77-
assert!(self
78-
.bot
79-
.iter()
80-
.any(|name| bot_name.eq_ignore_ascii_case(&format!("@{}", name))));
81-
bot_name.len()
82-
} else {
83-
panic!("no bot name?")
84-
};
78+
let tok = Tokenizer::new(&self.all[self.parsed..]);
8579
log::info!("identified potential command");
8680

8781
let mut success = vec![];
@@ -147,41 +141,55 @@ impl<'a> Input<'a> {
147141
);
148142
}
149143

150-
if self
151-
.ignore
152-
.overlaps_ignore((self.parsed)..(self.parsed + tok.position()))
153-
.is_some()
154-
{
155-
log::info!("command overlaps ignored block; ignore: {:?}", self.ignore);
156-
return None;
157-
}
158-
159144
let (mut tok, c) = success.pop()?;
160145
// if we errored out while parsing the command do not move the input forwards
161-
self.parsed += if c.is_ok() {
162-
tok.position()
163-
} else {
164-
name_length
165-
};
146+
if c.is_ok() {
147+
self.parsed += tok.position();
148+
}
166149
Some(c)
167150
}
151+
152+
/// Parses command for `r?`
153+
fn parse_review(&mut self) -> Option<Command<'a>> {
154+
let tok = Tokenizer::new(&self.all[self.parsed..]);
155+
match parse_single_command(assign::AssignCommand::parse_review, Command::Assign, &tok) {
156+
Some((mut tok, command)) => {
157+
self.parsed += tok.position();
158+
Some(command)
159+
}
160+
None => {
161+
log::warn!("expected r? parser to return something: {:?}", self.all);
162+
None
163+
}
164+
}
165+
}
168166
}
169167

170168
impl<'a> Iterator for Input<'a> {
171169
type Item = Command<'a>;
172170

173171
fn next(&mut self) -> Option<Command<'a>> {
174172
loop {
175-
let start = self
176-
.bot
177-
.iter()
178-
.filter_map(|name| find_command_start(&self.all[self.parsed..], name))
179-
.min()?;
180-
self.parsed += start;
181-
if let Some(command) = self.parse_command() {
173+
let caps = self.bot_re.captures(&self.all[self.parsed..])?;
174+
let m = caps.get(0).unwrap();
175+
if self
176+
.ignore
177+
.overlaps_ignore((self.parsed + m.start())..(self.parsed + m.end()))
178+
.is_some()
179+
{
180+
log::info!("command overlaps ignored block; ignore: {:?}", self.ignore);
181+
self.parsed += m.end();
182+
continue;
183+
}
184+
185+
self.parsed += m.end();
186+
if caps.name("review").is_some() {
187+
if let Some(command) = self.parse_review() {
188+
return Some(command);
189+
}
190+
} else if let Some(command) = self.parse_command() {
182191
return Some(command);
183192
}
184-
self.parsed += self.bot.len() + 1;
185193
}
186194
}
187195
}
@@ -230,6 +238,20 @@ fn code_2() {
230238
assert!(input.next().is_none());
231239
}
232240

241+
#[test]
242+
fn resumes_after_code() {
243+
// Handles a command after an ignored block.
244+
let input = "```
245+
@bot modify labels: +bug.
246+
```
247+
248+
@bot claim
249+
";
250+
let mut input = Input::new(input, vec!["bot"]);
251+
assert!(matches!(input.next(), Some(Command::Assign(Ok(_)))));
252+
assert_eq!(input.next(), None);
253+
}
254+
233255
#[test]
234256
fn edit_1() {
235257
let input_old = "@bot modify labels: +bug.";
@@ -277,3 +299,50 @@ fn multiname() {
277299
assert!(input.next().unwrap().is_ok());
278300
assert!(input.next().is_none());
279301
}
302+
303+
#[test]
304+
fn review_commands() {
305+
for (input, name) in [
306+
("r? @octocat", "octocat"),
307+
("r? octocat", "octocat"),
308+
("R? @octocat", "octocat"),
309+
("can I r? someone?", "someone"),
310+
("r? rust-lang/compiler", "rust-lang/compiler"),
311+
("r? @D--a--s-h", "D--a--s-h"),
312+
] {
313+
let mut input = Input::new(input, vec!["bot"]);
314+
assert_eq!(
315+
input.next(),
316+
Some(Command::Assign(Ok(assign::AssignCommand::ReviewName {
317+
name: name.to_string()
318+
})))
319+
);
320+
assert_eq!(input.next(), None);
321+
}
322+
}
323+
324+
#[test]
325+
fn review_errors() {
326+
use std::error::Error;
327+
for input in ["r?", "r? @", "r? @ user", "r?:user", "r?! @foo", "r?\nline"] {
328+
let mut input = Input::new(input, vec!["bot"]);
329+
let err = match input.next() {
330+
Some(Command::Assign(Err(err))) => err,
331+
c => panic!("unexpected {:?}", c),
332+
};
333+
assert_eq!(
334+
err.source().unwrap().downcast_ref(),
335+
Some(&assign::ParseError::NoUser)
336+
);
337+
assert_eq!(input.next(), None);
338+
}
339+
}
340+
341+
#[test]
342+
fn review_ignored() {
343+
// Checks for things that shouldn't be detected.
344+
for input in ["r", "reviewer? abc", "r foo"] {
345+
let mut input = Input::new(input, vec!["bot"]);
346+
assert_eq!(input.next(), None);
347+
}
348+
}

parser/src/command/assign.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub enum AssignCommand {
1717
Own,
1818
Release,
1919
User { username: String },
20+
ReviewName { name: String },
2021
}
2122

2223
#[derive(PartialEq, Eq, Debug)]
@@ -76,6 +77,20 @@ impl AssignCommand {
7677
return Ok(None);
7778
}
7879
}
80+
81+
/// Parses the input for `r?` command.
82+
pub fn parse_review<'a>(input: &mut Tokenizer<'a>) -> Result<Option<Self>, Error<'a>> {
83+
match input.next_token() {
84+
Ok(Some(Token::Word(name))) => {
85+
let name = name.strip_prefix('@').unwrap_or(name).to_string();
86+
if name.is_empty() {
87+
return Err(input.error(ParseError::NoUser));
88+
}
89+
Ok(Some(AssignCommand::ReviewName { name }))
90+
}
91+
_ => Err(input.error(ParseError::NoUser)),
92+
}
93+
}
7994
}
8095

8196
#[cfg(test)]
@@ -119,4 +134,47 @@ mod tests {
119134
Some(&ParseError::MentionUser),
120135
);
121136
}
137+
138+
fn parse_review<'a>(input: &'a str) -> Result<Option<AssignCommand>, Error<'a>> {
139+
let mut toks = Tokenizer::new(input);
140+
Ok(AssignCommand::parse_review(&mut toks)?)
141+
}
142+
143+
#[test]
144+
fn review_names() {
145+
for (input, name) in [
146+
("octocat", "octocat"),
147+
("@octocat", "octocat"),
148+
("rust-lang/compiler", "rust-lang/compiler"),
149+
("@rust-lang/cargo", "rust-lang/cargo"),
150+
("abc xyz", "abc"),
151+
("@user?", "user"),
152+
("@user.", "user"),
153+
("@user!", "user"),
154+
] {
155+
assert_eq!(
156+
parse_review(input),
157+
Ok(Some(AssignCommand::ReviewName {
158+
name: name.to_string()
159+
})),
160+
"failed on {input}"
161+
);
162+
}
163+
}
164+
165+
#[test]
166+
fn review_names_errs() {
167+
use std::error::Error;
168+
for input in ["", "@", "@ user"] {
169+
assert_eq!(
170+
parse_review(input)
171+
.unwrap_err()
172+
.source()
173+
.unwrap()
174+
.downcast_ref(),
175+
Some(&ParseError::NoUser),
176+
"failed on {input}"
177+
)
178+
}
179+
}
122180
}

src/handlers/assign.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ pub(super) async fn handle_command(
5151
);
5252
return Ok(());
5353
}
54+
AssignCommand::ReviewName { .. } => todo!(),
5455
};
5556
// Don't re-assign if already assigned, e.g. on comment edit
5657
if issue.contain_assignee(&username) {
@@ -109,6 +110,7 @@ pub(super) async fn handle_command(
109110
}
110111
};
111112
}
113+
AssignCommand::ReviewName { .. } => todo!(),
112114
};
113115
// Don't re-assign if aleady assigned, e.g. on comment edit
114116
if issue.contain_assignee(&to_assign) {

0 commit comments

Comments
 (0)