lol_chat_parser/
lib.rs

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}