using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using StardewModdingAPI.Web.Framework.LogParsing.Models; namespace StardewModdingAPI.Web.Framework.LogParsing { /// <summary>Parses SMAPI log files.</summary> public class LogParser { /********* ** Fields *********/ /// <summary>A regex pattern matching the start of a SMAPI message.</summary> private readonly Regex MessageHeaderPattern = new(@"^\[(?<time>\d\d[:\.]\d\d[:\.]\d\d) (?<level>[a-z]+)(?: +screen_(?<screen>\d+))? +(?<modName>[^\]]+)\] ", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's initial platform info message.</summary> private readonly Regex InfoLinePattern = new(@"^SMAPI (?<apiVersion>.+) with Stardew Valley (?<gameVersion>.+) on (?<os>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's mod folder path line.</summary> private readonly Regex ModPathPattern = new(@"^Mods go here: (?<path>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's log timestamp line.</summary> private readonly Regex LogStartedAtPattern = new(@"^Log started at (?<timestamp>.+) UTC", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's mod list.</summary> private readonly Regex ModListStartPattern = new(@"^Loaded \d+ mods:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's mod list.</summary> /// <remarks>The author name and description are optional.</remarks> private readonly Regex ModListEntryPattern = new(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's content pack list.</summary> private readonly Regex ContentPackListStartPattern = new(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary> private readonly Regex ContentPackListEntryPattern = new(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary> private readonly Regex ModUpdateListStartPattern = new(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's mod update list.</summary> private readonly Regex ModUpdateListEntryPattern = new(@"^ (?<name>.+) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's update line.</summary> private readonly Regex SmapiUpdatePattern = new(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /********* ** Public methods *********/ /// <summary>Parse SMAPI log text.</summary> /// <param name="logText">The SMAPI log text.</param> public ParsedLog Parse(string? logText) { try { // skip if empty if (string.IsNullOrWhiteSpace(logText)) { return new ParsedLog { IsValid = false, RawText = logText, Error = "The log is empty." }; } // init log ParsedLog log = new() { IsValid = true, RawText = logText, Messages = this.CollapseRepeats(this.GetMessages(logText)).ToArray() }; // parse log messages LogModInfo smapiMod = new(name: "SMAPI", author: "Pathoschild", version: "", description: "", loaded: true); LogModInfo gameMod = new(name: "game", author: "", version: "", description: "", loaded: true); IDictionary<string, List<LogModInfo>> mods = new Dictionary<string, List<LogModInfo>>(); bool inModList = false; bool inContentPackList = false; bool inModUpdateList = false; foreach (LogMessage message in log.Messages) { // collect stats if (message.Level == LogLevel.Error) { switch (message.Mod) { case "SMAPI": smapiMod.Errors++; break; case "game": gameMod.Errors++; break; default: if (mods.TryGetValue(message.Mod, out var entries)) { foreach (LogModInfo entry in entries) entry.Errors++; } break; } } // collect SMAPI metadata if (message.Mod == "SMAPI") { // update flags inModList = inModList && message.Level == LogLevel.Info && this.ModListEntryPattern.IsMatch(message.Text); inContentPackList = inContentPackList && message.Level == LogLevel.Info && this.ContentPackListEntryPattern.IsMatch(message.Text); inModUpdateList = inModUpdateList && message.Level == LogLevel.Alert && this.ModUpdateListEntryPattern.IsMatch(message.Text); // mod list if (!inModList && message.Level == LogLevel.Info && this.ModListStartPattern.IsMatch(message.Text)) { inModList = true; message.IsStartOfSection = true; message.Section = LogSection.ModsList; } else if (inModList) { Match match = this.ModListEntryPattern.Match(message.Text); string name = match.Groups["name"].Value; string version = match.Groups["version"].Value; string author = match.Groups["author"].Value; string description = match.Groups["description"].Value; if (!mods.TryGetValue(name, out List<LogModInfo>? entries)) mods[name] = entries = new List<LogModInfo>(); entries.Add(new LogModInfo(name: name, author: author, version: version, description: description, loaded: true)); message.Section = LogSection.ModsList; } // content pack list else if (!inContentPackList && message.Level == LogLevel.Info && this.ContentPackListStartPattern.IsMatch(message.Text)) { inContentPackList = true; message.IsStartOfSection = true; message.Section = LogSection.ContentPackList; } else if (inContentPackList) { Match match = this.ContentPackListEntryPattern.Match(message.Text); string name = match.Groups["name"].Value; string version = match.Groups["version"].Value; string author = match.Groups["author"].Value; string description = match.Groups["description"].Value; string forMod = match.Groups["for"].Value; if (!mods.TryGetValue(name, out List<LogModInfo>? entries)) mods[name] = entries = new List<LogModInfo>(); entries.Add(new LogModInfo(name: name, author: author, version: version, description: description, contentPackFor: forMod, loaded: true)); message.Section = LogSection.ContentPackList; } // mod update list else if (!inModUpdateList && message.Level == LogLevel.Alert && this.ModUpdateListStartPattern.IsMatch(message.Text)) { inModUpdateList = true; message.IsStartOfSection = true; message.Section = LogSection.ModUpdateList; } else if (inModUpdateList) { Match match = this.ModUpdateListEntryPattern.Match(message.Text); string name = match.Groups["name"].Value; string version = match.Groups["version"].Value; string link = match.Groups["link"].Value; if (mods.TryGetValue(name, out var entries)) { foreach (LogModInfo entry in entries) entry.SetUpdate(version, link); } message.Section = LogSection.ModUpdateList; } else if (message.Level == LogLevel.Alert && this.SmapiUpdatePattern.IsMatch(message.Text)) { Match match = this.SmapiUpdatePattern.Match(message.Text); string version = match.Groups["version"].Value; string link = match.Groups["link"].Value; smapiMod.SetUpdate(version, link); } // platform info line else if (message.Level == LogLevel.Info && this.InfoLinePattern.IsMatch(message.Text)) { Match match = this.InfoLinePattern.Match(message.Text); log.ApiVersion = match.Groups["apiVersion"].Value; log.GameVersion = match.Groups["gameVersion"].Value; log.OperatingSystem = match.Groups["os"].Value; smapiMod.OverrideVersion(log.ApiVersion); } // mod path line else if (message.Level == LogLevel.Info && this.ModPathPattern.IsMatch(message.Text)) { Match match = this.ModPathPattern.Match(message.Text); log.ModPath = match.Groups["path"].Value; int lastDelimiterPos = log.ModPath.LastIndexOfAny(new[] { '/', '\\' }); log.GamePath = lastDelimiterPos >= 0 ? log.ModPath[..lastDelimiterPos] : log.ModPath; } // log UTC timestamp line else if (message.Level == LogLevel.Trace && this.LogStartedAtPattern.IsMatch(message.Text)) { Match match = this.LogStartedAtPattern.Match(message.Text); log.Timestamp = DateTime.Parse(match.Groups["timestamp"].Value + "Z"); } } } // finalize log if (log.GameVersion != null) gameMod.OverrideVersion(log.GameVersion); log.Mods = new[] { gameMod, smapiMod }.Concat(mods.Values.SelectMany(p => p).OrderBy(p => p.Name)).ToArray(); return log; } catch (LogParseException ex) { return new ParsedLog { IsValid = false, Error = ex.Message, RawText = logText }; } catch (Exception ex) { return new ParsedLog { IsValid = false, Error = $"Parsing the log file failed. Technical details:\n{ex}", RawText = logText }; } } /********* ** Private methods *********/ /// <summary>Collapse consecutive repeats into the previous message.</summary> /// <param name="messages">The messages to filter.</param> private IEnumerable<LogMessage> CollapseRepeats(IEnumerable<LogMessage> messages) { LogMessage? next = null; foreach (LogMessage message in messages) { // new message if (next == null) { next = message; continue; } // repeat if (next.Level == message.Level && next.Mod == message.Mod && next.Text == message.Text) { next.Repeated++; continue; } // non-repeat message yield return next; next = message; } if (next != null) yield return next; } /// <summary>Split a SMAPI log into individual log messages.</summary> /// <param name="logText">The SMAPI log text.</param> /// <exception cref="LogParseException">The log text can't be parsed successfully.</exception> private IEnumerable<LogMessage> GetMessages(string logText) { LogMessageBuilder builder = new(); using StringReader reader = new(logText); while (true) { // read line string? line = reader.ReadLine(); if (line == null) break; // match header Match header = this.MessageHeaderPattern.Match(line); bool isNewMessage = header.Success; // start/continue message if (isNewMessage) { if (builder.Started) { yield return builder.Build()!; builder.Clear(); } Group screenGroup = header.Groups["screen"]; builder.Start( time: header.Groups["time"].Value, level: Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true), screenId: screenGroup.Success ? int.Parse(screenGroup.Value) : 0, // main player is always screen ID 0 mod: header.Groups["modName"].Value, text: line[header.Length..] ); } else { if (!builder.Started) throw new LogParseException("Found a log message with no SMAPI metadata. Is this a SMAPI log file?"); builder.AddLine(line); } } // end last message if (builder.Started) yield return builder.Build()!; } } }