summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web/Framework/LogParsing
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2018-02-24 17:54:31 -0500
committerJesse Plamondon-Willard <github@jplamondonw.com>2018-02-24 17:54:31 -0500
commit414cf5c197b5b59776d3dda914eb15710efb0868 (patch)
tree0393a95194ad78cf4440c68657b0488b7db6d68b /src/SMAPI.Web/Framework/LogParsing
parent5da8b707385b9851ff3f6442de58519125f5c96f (diff)
parentf2e8450706d1971d774f870081deffdb0c6b92eb (diff)
downloadSMAPI-414cf5c197b5b59776d3dda914eb15710efb0868.tar.gz
SMAPI-414cf5c197b5b59776d3dda914eb15710efb0868.tar.bz2
SMAPI-414cf5c197b5b59776d3dda914eb15710efb0868.zip
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI.Web/Framework/LogParsing')
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParseException.cs15
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs245
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs24
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs24
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/ModInfo.cs27
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs47
6 files changed, 382 insertions, 0 deletions
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs
new file mode 100644
index 00000000..5d4c8c08
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace StardewModdingAPI.Web.Framework.LogParsing
+{
+ /// <summary>An error while parsing the log file which doesn't require a stack trace to troubleshoot.</summary>
+ internal class LogParseException : Exception
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="message">The user-friendly error message.</param>
+ public LogParseException(string message) : base(message) { }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
new file mode 100644
index 00000000..23a1baa4
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -0,0 +1,245 @@
+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
+ {
+ /*********
+ ** 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>
+ private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) 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,
+ Error = "The log is empty."
+ };
+ }
+
+ // init log
+ ParsedLog log = new ParsedLog
+ {
+ IsValid = true,
+ 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 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,
+ RawTextIfError = logText
+ };
+ }
+ catch (Exception ex)
+ {
+ return new ParsedLog
+ {
+ IsValid = false,
+ Error = $"Parsing the log file failed. Technical details:\n{ex}",
+ RawTextIfError = 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;
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs
new file mode 100644
index 00000000..40d21bf8
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs
@@ -0,0 +1,24 @@
+namespace StardewModdingAPI.Web.Framework.LogParsing.Models
+{
+ /// <summary>The log severity levels.</summary>
+ public enum LogLevel
+ {
+ /// <summary>Tracing info intended for developers.</summary>
+ Trace,
+
+ /// <summary>Troubleshooting info that may be relevant to the player.</summary>
+ Debug,
+
+ /// <summary>Info relevant to the player. This should be used judiciously.</summary>
+ Info,
+
+ /// <summary>An issue the player should be aware of. This should be used rarely.</summary>
+ Warn,
+
+ /// <summary>A message indicating something went wrong.</summary>
+ Error,
+
+ /// <summary>Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue.</summary>
+ Alert
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs
new file mode 100644
index 00000000..baeac83c
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs
@@ -0,0 +1,24 @@
+namespace StardewModdingAPI.Web.Framework.LogParsing.Models
+{
+ /// <summary>A parsed log message.</summary>
+ public class LogMessage
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The local time when the log was posted.</summary>
+ public string Time { get; set; }
+
+ /// <summary>The log level.</summary>
+ public LogLevel Level { get; set; }
+
+ /// <summary>The mod name.</summary>
+ public string Mod { get; set; }
+
+ /// <summary>The log text.</summary>
+ public string Text { get; set; }
+
+ /// <summary>The number of times this message was repeated consecutively.</summary>
+ public int Repeated { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ModInfo.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ModInfo.cs
new file mode 100644
index 00000000..8c84ab38
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/ModInfo.cs
@@ -0,0 +1,27 @@
+namespace StardewModdingAPI.Web.Framework.LogParsing.Models
+{
+ /// <summary>Metadata about a mod or content pack in the log.</summary>
+ public class LogModInfo
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The mod author.</summary>
+ public string Author { get; set; }
+
+ /// <summary>The mod version.</summary>
+ public string Version { get; set; }
+
+ /// <summary>The mod description.</summary>
+ public string Description { get; set; }
+
+ /// <summary>The name of the mod for which this is a content pack (if applicable).</summary>
+ public string ContentPackFor { get; set; }
+
+ /// <summary>The number of errors logged by this mod.</summary>
+ public int Errors { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
new file mode 100644
index 00000000..31ef2fe1
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
@@ -0,0 +1,47 @@
+using System;
+
+namespace StardewModdingAPI.Web.Framework.LogParsing.Models
+{
+ /// <summary>Parsed metadata for a log.</summary>
+ public class ParsedLog
+ {
+ /*********
+ ** Accessors
+ *********/
+ /****
+ ** Metadata
+ ****/
+ /// <summary>Whether the log file was successfully parsed.</summary>
+ public bool IsValid { get; set; }
+
+ /// <summary>An error message indicating why the log file is invalid.</summary>
+ public string Error { get; set; }
+
+ /// <summary>The raw text if <see cref="IsValid"/> is false.</summary>
+ public string RawTextIfError { get; set; }
+
+ /****
+ ** Log data
+ ****/
+ /// <summary>The SMAPI version.</summary>
+ public string ApiVersion { get; set; }
+
+ /// <summary>The game version.</summary>
+ public string GameVersion { get; set; }
+
+ /// <summary>The player's operating system.</summary>
+ public string OperatingSystem { get; set; }
+
+ /// <summary>The mod folder path.</summary>
+ public string ModPath { get; set; }
+
+ /// <summary>The ISO 8601 timestamp when the log was started.</summary>
+ public DateTimeOffset Timestamp { get; set; }
+
+ /// <summary>Metadata about installed mods and content packs.</summary>
+ public LogModInfo[] Mods { get; set; } = new LogModInfo[0];
+
+ /// <summary>The log messages.</summary>
+ public LogMessage[] Messages { get; set; }
+ }
+}