using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using StardewModdingAPI.Common; using StardewModdingAPI.Web.Framework.LogParsing.Models; namespace StardewModdingAPI.Web.Framework.LogParsing { /// <summary>Parses SMAPI log files.</summary> public class LogParser { /********* ** Properties *********/ /// <summary>A regex pattern matching the start of a SMAPI message.</summary> private readonly Regex MessageHeaderPattern = new Regex(@"^\[(?<time>\d\d:\d\d:\d\d) (?<level>[a-z]+) +(?<modName>[^\]]+)\] ", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's initial platform info message.</summary> private readonly Regex InfoLinePattern = new Regex(@"^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 Regex(@"^Mods go here: (?<path>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's log timestamp line.</summary> private readonly Regex LogStartedAtPattern = new Regex(@"^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 Regex(@"^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 Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersionImpl.UnboundedVersionPattern + @")(?: 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 Regex(@"^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 Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?) \| (?<description>.+)$", 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 ParsedLog { IsValid = true, RawText = logText, Messages = this.CollapseRepeats(this.GetMessages(logText)).ToArray() }; // parse log messages LogModInfo smapiMod = new LogModInfo { Name = "SMAPI", Author = "Pathoschild", Description = "" }; IDictionary<string, LogModInfo> mods = new Dictionary<string, LogModInfo>(); bool inModList = false; bool inContentPackList = false; foreach (LogMessage message in log.Messages) { // collect stats if (message.Level == LogLevel.Error) { if (message.Mod == "SMAPI") smapiMod.Errors++; else if (mods.ContainsKey(message.Mod)) mods[message.Mod].Errors++; } // collect SMAPI metadata if (message.Mod == "SMAPI") { // update flags if (inModList && !this.ModListEntryPattern.IsMatch(message.Text)) inModList = false; if (inContentPackList && !this.ContentPackListEntryPattern.IsMatch(message.Text)) inContentPackList = false; // mod list if (!inModList && message.Level == LogLevel.Info && this.ModListStartPattern.IsMatch(message.Text)) inModList = true; 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; mods[name] = new LogModInfo { Name = name, Author = author, Version = version, Description = description }; } // content pack list else if (!inContentPackList && message.Level == LogLevel.Info && this.ContentPackListStartPattern.IsMatch(message.Text)) inContentPackList = true; 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; mods[name] = new LogModInfo { Name = name, Author = author, Version = version, Description = description, ContentPackFor = forMod }; } // 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.Version = log.ApiVersion; } // mod path line else if (message.Level == LogLevel.Debug && this.ModPathPattern.IsMatch(message.Text)) { Match match = this.ModPathPattern.Match(message.Text); log.ModPath = match.Groups["path"].Value; log.GamePath = new FileInfo(log.ModPath).Directory.FullName; } // 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"); } } } // finalise log log.Mods = new[] { smapiMod }.Concat(mods.Values.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; } 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) { LogMessage message = new LogMessage(); using (StringReader reader = new StringReader(logText)) { while (true) { // read data string line = reader.ReadLine(); if (line == null) break; Match header = this.MessageHeaderPattern.Match(line); // validate if (message.Text == null && !header.Success) throw new LogParseException("Found a log message with no SMAPI metadata. Is this a SMAPI log file?"); // start or continue message if (header.Success) { if (message.Text != null) yield return message; message = new LogMessage { Time = header.Groups["time"].Value, Level = Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true), Mod = header.Groups["modName"].Value, Text = line.Substring(header.Length) }; } else message.Text += "\n" + line; } // end last message if (message.Text != null) yield return message; } } } }