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
{
/// Parses SMAPI log files.
public class LogParser
{
/*********
** Fields
*********/
/// A regex pattern matching the start of a SMAPI message.
private readonly Regex MessageHeaderPattern = new(@"^\[(?\d\d[:\.]\d\d[:\.]\d\d) (?[a-z]+)(?: +screen_(?\d+))? +(?[^\]]+)\] ", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// A regex pattern matching SMAPI's initial platform info message.
private readonly Regex InfoLinePattern = new(@"^SMAPI (?.+) with Stardew Valley (?.+) on (?.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// A regex pattern matching SMAPI's mod folder path line.
private readonly Regex ModPathPattern = new(@"^Mods go here: (?.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// A regex pattern matching SMAPI's log timestamp line.
private readonly Regex LogStartedAtPattern = new(@"^Log started at (?.+) UTC", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// A regex pattern matching the start of SMAPI's mod list.
private readonly Regex ModListStartPattern = new(@"^Loaded \d+ mods:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// A regex pattern matching an entry in SMAPI's mod list.
/// The author name and description are optional.
private readonly Regex ModListEntryPattern = new(@"^ (?.+?) (?[^\s]+)(?: by (?[^\|]+))?(?: \| (?.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// A regex pattern matching the start of SMAPI's content pack list.
private readonly Regex ContentPackListStartPattern = new(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// A regex pattern matching an entry in SMAPI's content pack list.
private readonly Regex ContentPackListEntryPattern = new(@"^ (?.+?) (?[^\s]+)(?: by (?[^\|]+))? \| for (?[^\|]+)(?: \| (?.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// A regex pattern matching the start of SMAPI's mod update list.
private readonly Regex ModUpdateListStartPattern = new(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// A regex pattern matching an entry in SMAPI's mod update list.
private readonly Regex ModUpdateListEntryPattern = new(@"^ (?.+) (?[^\s]+): (? .+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// A regex pattern matching SMAPI's update line.
private readonly Regex SmapiUpdatePattern = new(@"^You can update SMAPI to (?[^\s]+): (? .+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/*********
** Public methods
*********/
/// Parse SMAPI log text.
/// The SMAPI log text.
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> mods = new Dictionary>();
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? entries))
mods[name] = entries = new List();
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? entries))
mods[name] = entries = new List();
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
*********/
/// Collapse consecutive repeats into the previous message.
/// The messages to filter.
private IEnumerable CollapseRepeats(IEnumerable 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;
}
/// Split a SMAPI log into individual log messages.
/// The SMAPI log text.
/// The log text can't be parsed successfully.
private IEnumerable 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(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()!;
}
}
}