1use std::collections::{HashMap, HashSet};
2
3use anyhow::{anyhow, Result};
4use pest::Parser;
5use pest_derive::Parser;
6use serde::Serialize;
7
8#[derive(Parser)]
9#[grammar = "grammar.pest"]
10
11pub struct LolChatParser;
12#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
13#[serde(rename_all = "snake_case")]
14pub enum ChatChannel {
15 All,
16 Team,
17 Party,
18 Player,
19}
20
21#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
22pub struct ChatMessage {
23 pub time: String,
24 pub channel: ChatChannel,
25 pub player: String,
26 pub champion: String,
27 pub text: String,
28}
29
30#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
31pub struct KillEvent {
32 pub time: String,
33 pub killer: String,
34 pub killer_champion: String,
35 pub victim: Option<String>,
36 pub victim_champion: Option<String>,
37 pub bounty: Option<u32>,
38 pub is_shutdown: bool,
39 pub is_first_blood: bool,
40}
41
42#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
43pub struct ObjectiveEvent {
44 pub time: String,
45 pub team: Option<String>,
46 pub description: String,
47}
48
49#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
50pub struct PlayerSummary {
51 pub name: String,
52 pub champions: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
56pub struct SystemLine {
57 pub text: String,
58}
59
60#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
61pub struct ParsedLog {
62 pub players: Vec<PlayerSummary>,
63 pub kills: Vec<KillEvent>,
64 pub events: Vec<ObjectiveEvent>,
65 pub messages: Vec<ChatMessage>,
66 pub system: Vec<SystemLine>,
67}
68
69pub fn parse_timestamp(line: &str) -> Result<String> {
70 let (time, _rest) = parse_time_and_rest(line)?;
71 Ok(time)
72}
73
74fn parse_time_and_rest(line: &str) -> Result<(String, String)> {
75 let mut pairs = LolChatParser::parse(Rule::line, line)
76 .map_err(|e| anyhow!("Parse error for `line`: {e}"))?;
77 let line_pair = pairs.next().ok_or_else(|| anyhow!("No match for `line`"))?;
78 let mut inner = line_pair.into_inner();
79
80 let time_pair = inner
81 .next()
82 .ok_or_else(|| anyhow!("No `time` pair inside `line`"))?;
83 debug_assert_eq!(time_pair.as_rule(), Rule::time);
84
85 let time = time_pair.as_str().to_string();
86
87 let rest = if let Some(idx) = line.find(' ') {
88 if idx + 1 < line.len() {
89 line[idx + 1..].to_string()
90 } else {
91 String::new()
92 }
93 } else {
94 String::new()
95 };
96
97 Ok((time, rest))
98}
99
100pub fn parse_log(input: &str) -> ParsedLog {
101 let mut players: HashMap<String, HashSet<String>> = HashMap::new();
102 let mut kills = Vec::new();
103 let mut events = Vec::new();
104 let mut messages = Vec::new();
105 let mut system = Vec::new();
106
107 for line in input.lines() {
108 let trimmed = line.trim();
109 if trimmed.is_empty() {
110 continue;
111 }
112
113 let (time, rest) = match parse_time_and_rest(trimmed) {
114 Ok(tr) => tr,
115 Err(_) => {
116 system.push(SystemLine {
117 text: trimmed.to_string(),
118 });
119 continue;
120 }
121 };
122
123 if let Some(chat) = parse_chat_message(&time, &rest) {
124 add_player(&mut players, &chat.player, &chat.champion);
125 messages.push(chat);
126 continue;
127 }
128
129 if let Some(kill) = parse_kill_event(&time, &rest) {
130 add_player(&mut players, &kill.killer, &kill.killer_champion);
131 if let (Some(victim), Some(vchamp)) = (&kill.victim, &kill.victim_champion) {
132 add_player(&mut players, victim, vchamp);
133 }
134 kills.push(kill);
135 continue;
136 }
137
138 if let Some(obj) = parse_objective_event(&time, &rest) {
139 if let Some((player, champ, _)) = parse_player_with_champion_prefix(&rest) {
140 add_player(&mut players, &player, &champ);
141 }
142
143 if let Some((target_player, target_champion)) = parse_target_player(&rest) {
144 add_player(&mut players, &target_player, &target_champion);
145 }
146
147 events.push(obj);
148 continue;
149 }
150
151 if let Some((player, champ, _)) = parse_player_with_champion_prefix(&rest) {
152 add_player(&mut players, &player, &champ);
153 }
154 }
155
156 let mut players_vec: Vec<PlayerSummary> = players
157 .into_iter()
158 .map(|(name, champs)| {
159 let mut list: Vec<String> = champs.into_iter().collect();
160 list.sort();
161 PlayerSummary {
162 name,
163 champions: list,
164 }
165 })
166 .collect();
167
168 players_vec.sort_by(|a, b| a.name.cmp(&b.name));
169
170 ParsedLog {
171 players: players_vec,
172 kills,
173 events,
174 messages,
175 system,
176 }
177}
178
179fn add_player(players: &mut HashMap<String, HashSet<String>>, player: &str, champion: &str) {
180 players
181 .entry(player.to_string())
182 .or_insert_with(HashSet::new)
183 .insert(champion.to_string());
184}
185
186fn parse_chat_message(time: &str, rest: &str) -> Option<ChatMessage> {
187 let trimmed = rest.trim_start();
188
189 let (channel, after_channel) = if trimmed.starts_with('[') {
190 let closing = trimmed.find(']')?;
191 let tag = &trimmed[1..closing];
192 let channel = match tag {
193 "All" => ChatChannel::All,
194 "Team" => ChatChannel::Team,
195 "Party" => ChatChannel::Party,
196 _ => return None,
197 };
198 let remainder = trimmed[closing + 1..].trim_start();
199 (channel, remainder)
200 } else {
201 (ChatChannel::Player, trimmed)
202 };
203
204 let (player, champion, after_pc) = parse_player_with_champion_prefix(after_channel)?;
205 let after_pc_trimmed = after_pc.trim_start();
206
207 if !after_pc_trimmed.starts_with(':') {
208 return None;
209 }
210 let message = after_pc_trimmed[1..].trim_start();
211
212 Some(ChatMessage {
213 time: time.to_string(),
214 channel,
215 player,
216 champion,
217 text: message.to_string(),
218 })
219}
220
221fn parse_player_with_champion_prefix(text: &str) -> Option<(String, String, &str)> {
222 let trimmed = text.trim_start();
223
224 let open = trimmed.find('(')?;
225 let close_rel = trimmed[open..].find(')')?;
226 let close = open + close_rel;
227
228 let player = trimmed[..open].trim();
229 if player.is_empty() {
230 return None;
231 }
232
233 let champion = trimmed[open + 1..close].trim();
234 if champion.is_empty() {
235 return None;
236 }
237
238 let remainder = &trimmed[close + 1..];
239 Some((player.to_string(), champion.to_string(), remainder))
240}
241
242fn parse_target_player(rest: &str) -> Option<(String, String)> {
243 let text = rest.trim();
244
245 let idx = text.find(" has targeted ")?;
246 let after = &text[idx + " has targeted ".len()..];
247
248 let dash_idx = after.find(" - (")?;
249 let target_name = after[..dash_idx].trim();
250 if target_name.is_empty() {
251 return None;
252 }
253
254 let after_dash = &after[dash_idx + " - (".len()..];
255 let close_paren = after_dash.find(')')?;
256 let champion = after_dash[..close_paren].trim();
257 if champion.is_empty() {
258 return None;
259 }
260
261 Some((target_name.to_string(), champion.to_string()))
262}
263
264fn parse_kill_event(time: &str, rest: &str) -> Option<KillEvent> {
265 let text = rest.trim();
266
267 if let Some(idx) = text.find(" has shut down ") {
268 let (killer_part, tail) = text.split_at(idx);
269 let tail = &tail[" has shut down ".len()..];
270
271 let (killer, killer_champ, _) = parse_player_with_champion_prefix(killer_part)?;
272
273 let exclam = tail.find('!')?;
274 let victim_part = &tail[..exclam];
275 let bonus_part = &tail[exclam + 1..];
276
277 let (victim, victim_champ, _) = parse_player_with_champion_prefix(victim_part)?;
278
279 let bonus_start = bonus_part.find("Bonus Bounty:")?;
280 let bonus_text = &bonus_part[bonus_start + "Bonus Bounty:".len()..];
281 let bonus_text = bonus_text.trim();
282 let digits: String = bonus_text
283 .chars()
284 .take_while(|c| c.is_ascii_digit())
285 .collect();
286 let bounty = digits.parse::<u32>().ok();
287
288 return Some(KillEvent {
289 time: time.to_string(),
290 killer,
291 killer_champion: killer_champ,
292 victim: Some(victim),
293 victim_champion: Some(victim_champ),
294 bounty,
295 is_shutdown: true,
296 is_first_blood: false,
297 });
298 }
299
300 if let Some(idx) = text.find(" has drawn first blood!") {
301 let (killer_part, _) = text.split_at(idx);
302 let (killer, killer_champ, _) = parse_player_with_champion_prefix(killer_part)?;
303
304 return Some(KillEvent {
305 time: time.to_string(),
306 killer,
307 killer_champion: killer_champ,
308 victim: None,
309 victim_champion: None,
310 bounty: None,
311 is_shutdown: false,
312 is_first_blood: true,
313 });
314 }
315
316 None
317}
318
319fn parse_objective_event(time: &str, rest: &str) -> Option<ObjectiveEvent> {
320 let text = rest.trim();
321
322 if text.contains(" has completed the ") {
323 let team_opt = if text.starts_with("Enemy team") {
324 Some("Enemy team".to_string())
325 } else if text.starts_with("Ally team") {
326 Some("Ally team".to_string())
327 } else if text.starts_with("Blue team") {
328 Some("Blue team".to_string())
329 } else if text.starts_with("Red team") {
330 Some("Red team".to_string())
331 } else {
332 None
333 };
334
335 return Some(ObjectiveEvent {
336 time: time.to_string(),
337 team: team_opt,
338 description: text.to_string(),
339 });
340 }
341
342 if text.contains("has targeted")
343 || text.contains("purchased ")
344 || text.contains("is on rampage!")
345 || text.contains(" is on the way")
346 || text.contains(" is missing")
347 || text.contains(" is retreating")
348 || text.contains(" is in danger")
349 || text.contains(" needs vision")
350 {
351 return Some(ObjectiveEvent {
352 time: time.to_string(),
353 team: None,
354 description: text.to_string(),
355 });
356 }
357
358 None
359}