diff --git a/ChobbyLauncher/ChobbyLauncher.csproj b/ChobbyLauncher/ChobbyLauncher.csproj index 2dfab0b91..19aff4d56 100644 --- a/ChobbyLauncher/ChobbyLauncher.csproj +++ b/ChobbyLauncher/ChobbyLauncher.csproj @@ -84,6 +84,7 @@ + @@ -162,7 +163,7 @@ - + diff --git a/ChobbyLauncher/CrashReportHelper.cs b/ChobbyLauncher/CrashReportHelper.cs index 1fb8f67b4..13c1d7c23 100644 --- a/ChobbyLauncher/CrashReportHelper.cs +++ b/ChobbyLauncher/CrashReportHelper.cs @@ -1,17 +1,15 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; -using System.Runtime.InteropServices; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows.Forms; using GameAnalyticsSDK.Net; using Octokit; using PlasmaShared; using ZkData; -using FileMode = System.IO.FileMode; namespace ChobbyLauncher { @@ -24,110 +22,306 @@ public enum CrashType { public static class CrashReportHelper { - private const string TruncatedString = "------- TRUNCATED -------"; + private const string CrashReportsRepoOwner = "ZeroK-RTS"; + private const string CrashReportsRepoName = "CrashReports"; + private const int MaxInfologSize = 62000; - public static Issue ReportCrash(string infolog, CrashType type, string engine, string bugReportTitle, string bugReportDescription) + private const string InfoLogLineStartPattern = @"(^\[t=\d+:\d+:\d+\.\d+\]\[f=-?\d+\] )"; + private const string InfoLogLineEndPattern = @"(\r?\n|\Z)"; + private sealed class GameFromLog { - try + public int StartIdxInLog { get; set; } + public string GameID { get; set; } + public bool HasDesync { get => FirstDesyncIdxInLog.HasValue; } + public int? FirstDesyncIdxInLog { get; set; } + public List GameStateFileNames { get; set; } + + //Perhaps in future versions, these could be added? + //PlayerName + //DemoFileName + //MapName + //ModName + } + + private sealed class GameFromLogCollection + { + public IReadOnlyList Games { get; private set; } + public GameFromLogCollection(IEnumerable startIndexes) { - var client = new GitHubClient(new ProductHeaderValue("chobbyla")); - client.Credentials = new Credentials(GlobalConst.CrashReportGithubToken); + Games = startIndexes.Select(idx => new GameFromLog { StartIdxInLog = idx }).ToArray(); + } - - infolog = Truncate(infolog, MaxInfologSize); + private readonly struct GameStartList : IReadOnlyList + { + private readonly GameFromLogCollection _this; + public GameStartList(GameFromLogCollection v) => _this = v; - var createdIssue = - client.Issue.Create("ZeroK-RTS", "CrashReports", new NewIssue($"Spring {type} [{engine}] {bugReportTitle}") { Body = $"{bugReportDescription}\n\n```{infolog}```", }) - .Result; + int IReadOnlyList.this[int index] => _this.Games[index].StartIdxInLog; - return createdIssue; + int IReadOnlyCollection.Count { get => _this.Games.Count; } + + IEnumerator IEnumerable.GetEnumerator() => _this.Games.Select(g => g.StartIdxInLog).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator(); } - catch (Exception ex) + public IReadOnlyList AsGameStartReadOnlyList() => new GameStartList(this); + + private GameFromLog GetGameForIndex(int idx) { - Trace.TraceWarning("Problem reporting a bug: {0}", ex); + //Equivalent to: + //return Games.LastOrDefault(g => g.StartIdxInLog < idx); + //but takes advantage of the fact that Games is sorted to have log rather than linear runtime. + var lb = AsGameStartReadOnlyList().LowerBoundIndex(idx); + return lb == 0 ? null : Games[lb - 1]; } - return null; - } + public void AddGameStateFileNames(IEnumerable<(int, string)> gameStateFileNames) + { + foreach (var file in gameStateFileNames) + { + var game = GetGameForIndex(file.Item1); + if (game == null) + { + Trace.TraceWarning("[CrashReportHelper] Unable to match GameState file to Game"); + continue; + } + if (game.GameStateFileNames == null) + { + game.GameStateFileNames = new List(); + } + + game.GameStateFileNames.Add(file.Item2); + } + } + public void AddDesyncs(IEnumerable desyncs) + { + foreach (var desync in desyncs) + { + var game = GetGameForIndex(desync); + if (game == null) + { + Trace.TraceWarning("[CrashReportHelper] Unable to match Desync to Game"); + continue; + } + if (!game.HasDesync) + { + game.FirstDesyncIdxInLog = desync; + } + } + } + public void AddGameIDs(IEnumerable<(int, string)> gameIDs) + { + foreach (var gameID in gameIDs) + { + var game = GetGameForIndex(gameID.Item1); + if (game == null) + { + Trace.TraceWarning("[CrashReportHelper] Unable to match GameID to Game"); + continue; + } + if (game.GameID != null) + { + Trace.TraceWarning("[CrashReportHelper] Found multiple GameIDs for Game"); + continue; + } - public static bool IsDesyncMessage(string msg) + game.GameID = gameID.Item2; + } + } + } + private static IReadOnlyList MakeRegionsOfInterest(int stringLength, IEnumerable pointsOfInterest, IReadOnlyList regionBoundaries) { - return !string.IsNullOrEmpty(msg) && msg.Contains(" Sync error for ") && msg.Contains(" in frame ") && msg.Contains(" correct is "); + //Pre: + // pointsOfInterest is sorted + // regionBoundaries is sorted + + //Automatically adds a region at the start and end of the string, that can expand to cover the whole string. + //For every element of pointsOfInterest, adds a region with StartLimit/EndLimit corresponding to the relevant regionBoundaries (or the start/end of the string) + + var result = new List(); + + result.Add(new TextTruncator.RegionOfInterest { PointOfInterest = 0, StartLimit = 0, EndLimit = stringLength }); + result.AddRange(pointsOfInterest.Select(poi => { + var regionEndIndex = Utils.LowerBoundIndex(regionBoundaries, poi); + return new TextTruncator.RegionOfInterest + { + PointOfInterest = poi, + StartLimit = regionEndIndex == 0 ? 0 : regionBoundaries[regionEndIndex - 1], + EndLimit = regionEndIndex == regionBoundaries.Count ? stringLength : regionBoundaries[regionEndIndex], + }; + })); + result.Add(new TextTruncator.RegionOfInterest { PointOfInterest = stringLength, StartLimit = 0, EndLimit = stringLength }); + + return result; } + private static string EscapeMarkdownTableCell(string str) => str.Replace("\r", "").Replace("\n", " ").Replace("|", @"\|"); + private static string MakeDesyncGameTable(GameFromLogCollection gamesFromLog) + { + var tableEmpty = true; + var sb = new StringBuilder(); + sb.AppendLine("\n\n|GameID|GameState File|"); + sb.AppendLine("|-|-|"); + + foreach (var game in gamesFromLog.Games.Where(g => g.HasDesync && g.GameStateFileNames != null)) + { + var gameIDString = EscapeMarkdownTableCell(game.GameID ?? "Unknown"); + foreach (var gameStateFileName in game.GameStateFileNames) + { + tableEmpty = false; + sb.AppendLine($"|{gameIDString}|{EscapeMarkdownTableCell(gameStateFileName)}|"); + } + } + return tableEmpty ? string.Empty : sb.ToString(); + } - private static string Truncate(string infolog, int maxSize) + private static async Task ReportCrash(string infolog, CrashType type, string engine, string bugReportTitle, string bugReportDescription, GameFromLogCollection gamesFromLog) { - if (infolog.Length > maxSize) // truncate infolog in middle + try { - var lines = infolog.Lines(); - var firstPart = new List(); - var lastPart = new List(); - int desyncFirst = -1; + var client = new GitHubClient(new ProductHeaderValue("chobbyla")) + { + Credentials = new Credentials(GlobalConst.CrashReportGithubToken) + }; - for (int a = 0; a < lines.Length;a++) - if (IsDesyncMessage(lines[a])) - { - desyncFirst = a; - break; - } + var infologTruncated = TextTruncator.Truncate(infolog, MaxInfologSize, MakeRegionsOfInterest(infolog.Length, gamesFromLog.Games.Where(g => g.HasDesync).Select(g => g.FirstDesyncIdxInLog.Value), gamesFromLog.AsGameStartReadOnlyList())); - if (desyncFirst != -1) + var desyncDebugInfo = MakeDesyncGameTable(gamesFromLog); + + var newIssueRequest = new NewIssue($"Spring {type} [{engine}] {bugReportTitle}") { - var sumSize = 0; - var firstIndex = desyncFirst; - var lastIndex = desyncFirst + 1; - do - { - if (firstIndex >= 0) - { - firstPart.Add(lines[firstIndex]); - sumSize += lines[firstIndex].Length; - } - if (lastIndex < lines.Length) - { - lastPart.Add(lines[lastIndex]); - sumSize += lines[lastIndex].Length; - } + Body = $"{bugReportDescription}{desyncDebugInfo}" + }; + var createdIssue = await client.Issue.Create(CrashReportsRepoOwner, CrashReportsRepoName, newIssueRequest); - firstIndex--; - lastIndex++; + await client.Issue.Comment.Create(CrashReportsRepoOwner, CrashReportsRepoName, createdIssue.Number, $"infolog_full.txt (truncated):\n\n```{infologTruncated}```"); - } while (sumSize < MaxInfologSize && (firstIndex > 0 || lastIndex < lines.Length)); - if (lastIndex < lines.Length) lastPart.Add(TruncatedString); - if (firstIndex > 0) firstPart.Add(TruncatedString); - firstPart.Reverse(); - } - else - { + return createdIssue; + } + catch (Exception ex) + { + Trace.TraceWarning("[CrashReportHelper] Problem reporting a bug: {0}", ex); + } + return null; + } + + //All infolog parsing is best-effort only. + //The infolog file format does not have enough structure to guarantee that it is never misinterpreted. + + private static int[] ReadGameReloads(string logStr) + { + //Game reload detected by [f=-000001] that follows either the start of the file, or [f={non-negative value}] + + var list = new List(); - var sumSize = 0; + var negativeFrameRegex = new Regex(@"f=-(?<=(?^)\[t=\d+:\d+:\d+\.\d+\]\[f=-)\d+\]", RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Multiline, TimeSpan.FromSeconds(30)); + var nonNegativeFrameRegex = new Regex(@"f=\d(?<=^\[t=\d+:\d+:\d+\.\d+\]\[f=\d)\d*\]", RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Multiline, TimeSpan.FromSeconds(30)); - for (int i = 0; i < lines.Length; i++) + var idx = 0; + + try + { + while (true) + { { - int index = i%2 == 0 ? i/2 : lines.Length - i/2 - 1; - if (sumSize + lines[index].Length < maxSize) - { - if (i%2 == 0) firstPart.Add(lines[index]); - else lastPart.Add(lines[index]); - } - else - { - firstPart.Add(TruncatedString); - break; - } - sumSize += lines[index].Length; + var m = negativeFrameRegex.Match(logStr, idx); + if (!m.Success) break; + idx = m.Index; + list.Add(m.Groups["s"].Index); + } + { + var m = nonNegativeFrameRegex.Match(logStr, idx); + if (!m.Success) break; + idx = m.Index; } - lastPart.Reverse(); } + return list.ToArray(); + } + catch (RegexMatchTimeoutException) + { + Trace.TraceError("\"[CrashReportHelper] RegexMatchTimeoutException in ReadGameReloads"); + return Array.Empty(); + } + } + private static (int, string)[] ReadGameStateFileNames(string logStr) + { + //See https://github.com/beyond-all-reason/spring/blob/f3ba23635e1462ae2084f10bf9ba777467d16090/rts/System/Sync/DumpState.cpp#L155 - infolog = string.Join("\r\n", firstPart) + "\r\n" + string.Join("\r\n", lastPart); + //[t=00:22:43.353840][f=0003461] [DumpState] using dump-file "ClientGameState--749245531-[3461-3461].txt" + try + { + return + Regex + .Matches( + logStr, + $@"(?<={InfoLogLineStartPattern})\[DumpState\] using dump-file ""(?[^{Regex.Escape(System.IO.Path.DirectorySeparatorChar.ToString())}{Regex.Escape(System.IO.Path.AltDirectorySeparatorChar.ToString())}""]+)""{InfoLogLineEndPattern}", + RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Multiline, + TimeSpan.FromSeconds(30)) + .Cast().Select(m => (m.Index, m.Groups["d"].Value)).Distinct() + .ToArray(); + } + catch (RegexMatchTimeoutException) + { + Trace.TraceError("\"[CrashReportHelper] RegexMatchTimeoutException in ReadClientStateFileNames"); + return Array.Empty<(int, string)>(); + } + } + + private static int[] ReadDesyncs(string logStr) + { + //[t=00:22:43.533864][f=0003461] Sync error for mankarse in frame 3451 (got 927a6f33, correct is 6b550dd1) + + //See ZkData.Account.IsValidLobbyName + var accountNamePattern = @"[_[\]a-zA-Z0-9]{1,25}"; + try + { + return + Regex + .Matches( + logStr, + $@"Sync error for(?<={InfoLogLineStartPattern}Sync error for) {accountNamePattern} in frame \d+ \(got [a-z0-9]+, correct is [a-z0-9]+\){InfoLogLineEndPattern}", + RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Multiline, + TimeSpan.FromSeconds(30)) + .Cast().Select(m => m.Index).ToArray(); + } + catch (RegexMatchTimeoutException) + { + Trace.TraceError("\"[CrashReportHelper] RegexMatchTimeoutException in ReadDesyncs"); + return Array.Empty(); } - return infolog; } - + + + private static (int, string)[] ReadGameIDs(string logStr) + { + //[t=00:19:00.246149][f=-000001] GameID: 6065f665e92c7942def2c0c17c703e72 + + try + { + return + Regex + .Matches( + logStr, + $@"GameID:(?<={InfoLogLineStartPattern}GameID:) (?[0-9a-zA-Z]+){InfoLogLineEndPattern}", + RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Multiline, + TimeSpan.FromSeconds(30)) + .Cast().Select(m => { var g = m.Groups["g"]; return (g.Index, g.Value); }).ToArray(); + } + catch (RegexMatchTimeoutException) + { + Trace.TraceError("\"[CrashReportHelper] RegexMatchTimeoutException in ReadGameIDs"); + return Array.Empty<(int, string)>(); + } + } + public static void CheckAndReportErrors(string logStr, bool springRunOk, string bugReportTitle, string bugReportDescription, string engineVersion) { - var syncError = CrashReportHelper.IsDesyncMessage(logStr); + var gamesFromLog = new GameFromLogCollection(ReadGameReloads(logStr)); + + gamesFromLog.AddGameStateFileNames(ReadGameStateFileNames(logStr)); + gamesFromLog.AddDesyncs(ReadDesyncs(logStr)); + gamesFromLog.AddGameIDs(ReadGameIDs(logStr)); + + var syncError = gamesFromLog.Games.Any(g => g.HasDesync); if (syncError) Trace.TraceWarning("Sync error detected"); var openGlFail = logStr.Contains("No OpenGL drivers installed.") || @@ -177,11 +371,16 @@ public static void CheckAndReportErrors(string logStr, bool springRunOk, string : luaErr ? CrashType.LuaError : CrashType.Crash; - var ret = ReportCrash(logStr, - crashType, - engineVersion, - bugReportTitle, - bugReportDescription); + var ret = + ReportCrash( + logStr, + crashType, + engineVersion, + bugReportTitle, + bugReportDescription, + gamesFromLog) + .GetAwaiter().GetResult(); + if (ret != null) try { diff --git a/ChobbyLauncher/TextTruncator.cs b/ChobbyLauncher/TextTruncator.cs new file mode 100644 index 000000000..fa725dd10 --- /dev/null +++ b/ChobbyLauncher/TextTruncator.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Linq; + +namespace ChobbyLauncher +{ + public static class TextTruncator + { + private static readonly string TruncationMarker = "------- TRUNCATED -------" + Environment.NewLine; + + public struct RegionOfInterest + { + public int PointOfInterest; + public int StartLimit; + public int EndLimit; + } + + private struct Region + { + public int Start; + public int End; + public int Length { get => End - Start; } + + public int StartLimit; + public int EndLimit; + + public static Region Merge(Region a, Region b) => + new Region + { + Start = Math.Min(a.Start, b.Start), + End = Math.Max(a.End, b.End), + StartLimit = Math.Min(a.StartLimit, b.StartLimit), + EndLimit = Math.Max(a.EndLimit, b.EndLimit), + }; + } + private struct TruncatedTextBuilder + { + private int LineAlignForwards(int pos) + { + if (pos == 0) + { + return pos; + } + var nextNewline = _initialString.IndexOf('\n', pos - 1); + return nextNewline == -1 ? _initialString.Length : nextNewline + 1; + } + private int LineAlignBackwards(int pos) + { + if (pos == 0) + { + return pos; + } + var prevNewline = _initialString.LastIndexOf('\n', pos - 1); + return prevNewline + 1; + } + + private readonly string _initialString; + private readonly int _maxLength; + private List Regions { get; set; } + + //Should always equal: + // Regions.Sum(r => r.Length) + private int RegionLengthSum { get; set; } + + //Should always equal: + // Regions.Any(r => r.Start == 0) + private bool HasRegionAtStart { get; set; } + //Should always equal: + // Regions.Any(r => r.End == _initialString.Length) + private bool HasRegionAtEnd { get; set; } + + private int LengthEstimate { get => RegionLengthSum + TruncationMarker.Length * ((Regions.Count - 1) + (HasRegionAtStart ? 0 : 1) + (HasRegionAtEnd ? 0 : 1)); } + + private bool LengthChangeImpossible(int lengthChange) => LengthEstimate > _maxLength - lengthChange; + + private int PredictLengthChangeFromTerminalTruncationMarkers(int newRegionStart, int newRegionEnd) => + //If a region will be at the start of _initialString, and + //there was not previously a region at the start of _initialString; + //the TruncationMarker at the start of the result is no longer needed; + //so there will be a negative length change. + + //(This is also true for regions at the end of _initialString). + TruncationMarker.Length * + ( + (!HasRegionAtStart && newRegionStart == 0 ? -1 : 0) + + (!HasRegionAtEnd && newRegionEnd == _initialString.Length ? -1 : 0) + ); + + private void UpdateLengthEstimate(int newRegionStart, int newRegionEnd, int regionLengthChange) + { + HasRegionAtStart = HasRegionAtStart || newRegionStart == 0; + HasRegionAtEnd = HasRegionAtEnd || newRegionEnd == _initialString.Length; + RegionLengthSum += regionLengthChange; + } + + public TruncatedTextBuilder(string initialString, int maxLength, int expectedRegionCount) + { + _initialString = initialString; + _maxLength = maxLength; + Regions = new List(expectedRegionCount); + RegionLengthSum = 0; + HasRegionAtStart = false; + HasRegionAtEnd = false; + } + + public bool AddRegion(RegionOfInterest newRegion) + { + //Cleans region before inserting it into Regions: + // Does not allow Regions to contain any Region with Length=0. + // Does not allow Regions to contain any Region where Start/End/StartLimit/EndLimit are not aligned to line boundaries. + // Does not allow Regions to contain any Region where Start 0 && region.Start <= Regions[Regions.Count - 1].End; + Region finalRegion; + int regionLengthChange; + if (mergeRegion) + { + finalRegion = Region.Merge(Regions[Regions.Count - 1], region); + regionLengthChange = finalRegion.Length - Regions[Regions.Count - 1].Length; + } + else + { + finalRegion = region; + regionLengthChange = region.Length; + } + + var lengthChange = + regionLengthChange + + (mergeRegion ? 0 : TruncationMarker.Length) + + PredictLengthChangeFromTerminalTruncationMarkers(finalRegion.Start, finalRegion.End); + + if (LengthChangeImpossible(lengthChange)) + { + return false; + } + + UpdateLengthEstimate(finalRegion.Start, finalRegion.End, regionLengthChange); + if (mergeRegion) + { + Regions[Regions.Count - 1] = finalRegion; + } + else + { + Regions.Add(finalRegion); + } + + return true; + } + + public bool ExpandRegions() + { + var expanded = false; + var expansionLocationIndex = new int[Regions.Count << 1]; + var distancesToLimit = new int[Regions.Count << 1]; + for (var i = 0; i != Regions.Count; ++i) + { + expansionLocationIndex[i << 1] = i << 1; + distancesToLimit[i << 1] = Regions[i].Start - Regions[i].StartLimit; + + expansionLocationIndex[(i << 1) + 1] = (i << 1) + 1; + distancesToLimit[(i << 1) + 1] = Regions[i].EndLimit - Regions[i].End; + } + + Array.Sort(distancesToLimit, expansionLocationIndex); + + var remainingAmountToExpand = _maxLength - LengthEstimate; + + for (var i = 0; i != expansionLocationIndex.Length; ++i) + { + var ri = expansionLocationIndex[i] >> 1; + var remainingRegions = expansionLocationIndex.Length - i; + var amountToExpand = remainingAmountToExpand / remainingRegions; + + int newStart; + int newEnd; + int regionLengthChange; + + if (distancesToLimit[i] <= amountToExpand) + { + //Expand by distancesToLimit[i] + if ((expansionLocationIndex[i] & 1) == 0) + { + newStart = Regions[ri].StartLimit; + newEnd = Regions[ri].End; + } + else + { + newStart = Regions[ri].Start; + newEnd = Regions[ri].EndLimit; + } + + remainingAmountToExpand -= distancesToLimit[i] + PredictLengthChangeFromTerminalTruncationMarkers(newStart, newEnd); + regionLengthChange = distancesToLimit[i]; + } + else + { + //Expand by amountToExpand (snap to line boundary) + var oldStart = Regions[ri].Start; + var oldEnd = Regions[ri].End; + if ((expansionLocationIndex[i] & 1) == 0) + { + newStart = LineAlignForwards(oldStart - amountToExpand); + newEnd = oldEnd; + regionLengthChange = oldStart - newStart; + } + else + { + newStart = oldStart; + newEnd = LineAlignBackwards(oldEnd + amountToExpand); + regionLengthChange = newEnd - oldEnd; + } + + //Reduce by amountToExpand, rather than the actual distance that was expanded, for fairness between expansion points. + remainingAmountToExpand -= amountToExpand; + } + UpdateLengthEstimate(newStart, newEnd, regionLengthChange); + Regions[ri] = new Region + { + Start = newStart, + End = newEnd, + StartLimit = Regions[ri].StartLimit, + EndLimit = Regions[ri].EndLimit, + }; + if (regionLengthChange > 0) + { + expanded = true; + } + } + return expanded; + } + + public void MergeRegions() + { + var i = 0; + while (i + 1 < Regions.Count) + { + if (Regions[i].End >= Regions[i + 1].Start) + { + var mergedRegion = Region.Merge(Regions[i], Regions[i + 1]); + RegionLengthSum += mergedRegion.Length - (Regions[i].Length + Regions[i + 1].Length); + Regions[i] = mergedRegion; + Regions.RemoveAt(i + 1); + } + else + { + ++i; + } + } + } + + public override string ToString() + { + //Always returns a string with a Length matching LengthEstimate. + //If Regions is not merged, the locations of the inserted + //TruncationMarkers might not be what you expect. + + if (Regions.Count == 0) + { + return TruncationMarker; + } + var sb = new StringBuilder(LengthEstimate); + if (!HasRegionAtStart) + { + sb.Append(TruncationMarker); + } + var i = 0; + { + var r = Regions[i++]; + sb.Append(_initialString, r.Start, r.Length); + } + while (i != Regions.Count) + { + var r = Regions[i++]; + sb.Append(TruncationMarker); + sb.Append(_initialString, r.Start, r.Length); + } + if (!HasRegionAtEnd) + { + sb.Append(TruncationMarker); + } + return sb.ToString(); + } + } + + public static string Truncate(string str, int maxLength, IReadOnlyList regionsOfInterest) + { + //Requires: + // maxLength >= 0 + // regionsOfInterest is sorted by PointOfInterest + // regionsOfInterest.All(r => r.StartLimit <= r.PointOfInterest && r.PointOfInterest <= r.EndLimit) + // regionsOfInterest.All(r => 0 <= r.PointOfInterest && r.PointOfInterest <= str.Length) + // regionsOfInterest.All(r => 0 <= r.EndLimit && r.EndLimit <= str.Length) + // regionsOfInterest.All(r => 0 <= r.StartLimit && r.StartLimit <= str.Length) + + if (maxLength < 0) + { + throw new ArgumentOutOfRangeException(nameof(maxLength)); + } + + if (!regionsOfInterest + .Zip(regionsOfInterest.Skip(1), (l, r) => new { l, r }) + .All(p => p.l.PointOfInterest <= p.r.PointOfInterest)) + { + throw new ArgumentException("Not sorted by PointOfInterest", nameof(regionsOfInterest)); + } + + if (!regionsOfInterest.All(r => r.StartLimit <= r.PointOfInterest && r.PointOfInterest <= r.EndLimit)) + { + throw new ArgumentException("Contains RegionOfInterest for which PointOfInterest not between StartLimit and EndLimit", nameof(regionsOfInterest)); + } + + if (!( + regionsOfInterest.All(r => 0 <= r.PointOfInterest && r.PointOfInterest <= str.Length) + && regionsOfInterest.All(r => 0 <= r.EndLimit && r.EndLimit <= str.Length) + && regionsOfInterest.All(r => 0 <= r.StartLimit && r.StartLimit <= str.Length))) + { + throw new ArgumentException("Contains RegionOfInterest that is not within str", nameof(regionsOfInterest)); + } + + + //Returns: A version of str for which Length <= maxLength + // To reduce the length, lines are removed and replaced with TruncationMarker + // When removing lines, lines that are within regionsOfInterest are preferentially kept + + if (str.Length <= maxLength) + { + return str; + } + + //Special handling if maxLength < TruncationMarker.Length + if (maxLength < TruncationMarker.Length) + { + return string.Empty; + } + + var result = new TruncatedTextBuilder(str, maxLength, regionsOfInterest.Count); + + //Convert "regionsOfInterest" to regions + // Add them to TruncatedTextBuilder incrementally. Stop if maxLength is exceeded + // Snap PointOfInterest/StartLimit/EndLimit to line boundaries + foreach (var roi in regionsOfInterest) + { + if (!result.AddRegion(roi)) + { + return result.ToString(); + } + } + while (result.ExpandRegions()) + { + result.MergeRegions(); + } + + return result.ToString(); + } + } +} diff --git a/Shared/PlasmaShared/Utils.cs b/Shared/PlasmaShared/Utils.cs index 3309cad71..249f1dc81 100644 --- a/Shared/PlasmaShared/Utils.cs +++ b/Shared/PlasmaShared/Utils.cs @@ -200,6 +200,35 @@ public static double StdDevSquared(this IEnumerable values) } + public static int LowerBoundIndex(this IReadOnlyList list, T value) where T : IComparable + { + //Requires: + // list is sorted + + //Returns: + // Smallest value of i for which: !(list[i] < value) + // or list.Count, if there is no such value. + + int first = 0; + int len = list.Count; + + while (len > 0) + { + var half = len / 2; + var mid = first + half; + if (list[mid].CompareTo(value) < 0) + { + first = mid + 1; + len = len - half - 1; + } + else + { + len = half; + } + } + + return first; + } public static bool CanRead(string filename)