From d3518e6e0bb6b7707ea7c6c90fc4a34f7503b83a Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 8 Sep 2026 09:27:53 -0400 Subject: [PATCH 1/6] Parse the WinGet CLI table without relying on English headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WinGet packages were not detected at all when Windows runs a non-English display language (#5355: "Found 0 installed packages from Winget" in Japanese, 202 in English). WinGetCliHelper located the table columns by searching the header line for the literal words "Id", "Version", "Available" and "Source"; winget translates those (名前 / ID / バージョン / 利用可能 / ソース), so every IndexOf returned -1, the row guard IdIndex > 0 && VersionIndex > 0 never passed, and every row was dropped while the process still exited 0. This hits list, upgrade and search alike, and only surfaces for users whose COM backend is unavailable and who therefore fall back to CLI parsing. Derive the columns structurally instead. WinGetTableLayout takes the header line plus its dash separator and records each header token's start as a display column, then slices data rows back by display column. Name/Id/Version are boundaries 0/1/2 and Source is the last boundary, so a header word that itself contains a space (Korean "사용 가능" for Available) cannot shift them; the Available cell simply spans boundary 3 through the last one. Columns are padded by display width, not character count, so the conversion is width-aware; this replaces the ad-hoc "offset" walk-back, which only happened to work when every wide character sat left of the Id column. The width table covers only blocks that are wholly East Asian Wide/Fullwidth. Emoji blocks are deliberately excluded because ICU's width data has Neutral holes inside them: underestimating is the safe direction, since a boundary that lands too far right is recovered by snapping back to the start of the token it landed in, whereas overestimating would cut a cell short. Ambiguous-width characters count as 1, which is what winget does - verified against real Japanese output, where column starts land exactly on 0/38/76 only under that rule. Splitting the header on runs of two or more spaces is not viable: winget pads a cell to its column width plus exactly one space, so a header that is the widest value in its column is separated from the next by a single space ("... Available Source"). Rows are also rejected when they are wider than the separator line. A real row can never exceed it, and this keeps winget's localized trailing messages out of the results - the existing "have pins" skip only matches the English text, and the Japanese equivalent is long enough to reach the Available column in a narrow table. Two incidental fixes fall out of slicing cells by their real bounds: Add/Remove-programs identifiers containing spaces are no longer truncated at the first space (ARP\Machine\X86\Microsoft Copilot was read as ARP\Machine\X86\Microsoft, which no winget command could act on), and a row padded through a blank Source column now resolves to its local source rather than a source named "". The three parse loops are extracted into static methods over a line sequence so they can be tested directly; the process loops now feed them a logging iterator. Verified with a differential against real captured output: upgrade and search are byte-identical to the previous parser over 20 and 26 rows, and list differs on exactly the three ARP rows above out of 157. On real Japanese output the previous parser yields 0 packages and this one yields the full list. --- .../ClientHelpers/WinGetCliHelper.cs | 329 +++++------ .../ClientHelpers/WinGetTableLayout.cs | 199 +++++++ .../WinGetCliParsingTests.cs | 532 ++++++++++++++++++ 3 files changed, 874 insertions(+), 186 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs index 2a3eeaaee8..ff5649e5e3 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs @@ -76,83 +76,82 @@ public IReadOnlyList GetAvailableUpdates_UnSafe() p.Start(); - string OldLine = ""; - int IdIndex = -1; - int VersionIndex = -1; - int NewVersionIndex = -1; - int SourceIndex = -1; - bool DashesPassed = false; + Packages.AddRange(ParseAvailableUpdates(Manager, ReadOutputLines(p, logger))); + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + + return Packages; + } + + private static IEnumerable ReadOutputLines(Process p, IProcessTaskLogger logger) + { string? line; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); + yield return line; + } + } + internal static IReadOnlyList ParseAvailableUpdates( + WinGet manager, + IEnumerable outputLines + ) + { + List packages = []; + string previousLine = ""; + WinGetTableLayout? layout = null; + + foreach (string line in outputLines) + { if (line.Contains("have pins")) { continue; } - if (!DashesPassed && line.Contains("---")) + if (WinGetTableLayout.IsSeparatorLine(line)) { - string HeaderPrefix = OldLine.Contains("SearchId") ? "Search" : ""; - string HeaderSuffix = OldLine.Contains("SearchId") ? "Header" : ""; - IdIndex = OldLine.IndexOf(HeaderPrefix + "Id", StringComparison.InvariantCulture); - VersionIndex = OldLine.IndexOf( - HeaderPrefix + "Version", - StringComparison.InvariantCulture - ); - NewVersionIndex = OldLine.IndexOf( - "Available" + HeaderSuffix, - StringComparison.InvariantCulture - ); - SourceIndex = OldLine.IndexOf( - HeaderPrefix + "Source", - StringComparison.InvariantCulture - ); - DashesPassed = true; + layout = WinGetTableLayout.Parse(previousLine, line); } - else if (line.Trim() == "") + else if (string.IsNullOrWhiteSpace(line)) { - DashesPassed = false; + layout = null; } else if ( - DashesPassed - && IdIndex > 0 - && VersionIndex > 0 - && NewVersionIndex > 0 - && IdIndex < VersionIndex - && VersionIndex < NewVersionIndex - && NewVersionIndex < line.Length + layout is not null + && layout.ColumnCount >= 4 + && layout.IsRowReaching(line, WinGetTableLayout.AvailableColumn) ) { - int offset = 0; // Account for non-unicode character length - while (line[IdIndex - offset - 1] != ' ' || offset > (IdIndex - 5)) - { - offset++; - } + string name = layout.GetCell(line, WinGetTableLayout.NameColumn); + string id = layout.GetCell(line, WinGetTableLayout.IdColumn); + string version = layout.GetCell(line, WinGetTableLayout.VersionColumn); - string name = line[..(IdIndex - offset)].Trim(); - string id = line[(IdIndex - offset)..].Trim().Split(' ')[0]; - string version = line[(VersionIndex - offset)..(NewVersionIndex - offset)].Trim(); string newVersion; - if (SourceIndex != -1) - { - newVersion = line[(NewVersionIndex - offset)..(SourceIndex - offset)].Trim(); - } - else - { - newVersion = line[(NewVersionIndex - offset)..].Trim().Split(' ')[0]; - } - IManagerSource source; - if (SourceIndex == -1 || SourceIndex >= line.Length) + if (layout.ColumnCount >= 5) { - source = Manager.DefaultSource; + newVersion = layout.GetCell( + line, + WinGetTableLayout.AvailableColumn, + layout.LastColumn + ); + string sourceName = layout.GetCell(line, layout.LastColumn); + source = + sourceName.Length == 0 + ? manager.DefaultSource + : manager.SourcesHelper.Factory.GetSourceOrDefault(sourceName); } else { - string sourceName = line[(SourceIndex - offset)..].Trim().Split(' ')[0]; - source = Manager.SourcesHelper.Factory.GetSourceOrDefault(sourceName); + newVersion = layout.GetCell( + line, + WinGetTableLayout.AvailableColumn, + layout.ColumnCount + ); + source = manager.DefaultSource; } // Restore the version we last upgraded to when WinGet reports it as unknown (#5158). @@ -160,11 +159,14 @@ public IReadOnlyList GetAvailableUpdates_UnSafe() if (versionUnknown) version = WinGetPkgOperationHelper.GetLastInstalledVersion(id); - var package = new Package(name, id, version, newVersion, source, Manager); + var package = new Package(name, id, version, newVersion, source, manager); // Skip one-shot suppression for unknown versions so the restored mark isn't cleared. - if (versionUnknown || !WinGetPkgOperationHelper.ConsumeAlreadyUpgradedSuppression(package)) + if ( + versionUnknown + || !WinGetPkgOperationHelper.ConsumeAlreadyUpgradedSuppression(package) + ) { - Packages.Add(package); + packages.Add(package); } else { @@ -173,14 +175,11 @@ public IReadOnlyList GetAvailableUpdates_UnSafe() ); } } - OldLine = line; - } - logger.AddToStdErr(p.StandardError.ReadToEnd()); - p.WaitForExit(); - logger.Close(p.ExitCode); + previousLine = line; + } - return Packages; + return packages; } public IReadOnlyList GetInstalledPackages_UnSafe() @@ -221,88 +220,58 @@ public IReadOnlyList GetInstalledPackages_UnSafe() p.Start(); - string OldLine = ""; - int IdIndex = -1; - int VersionIndex = -1; - int SourceIndex = -1; - int NewVersionIndex = -1; - bool DashesPassed = false; - string? line; - while ((line = p.StandardOutput.ReadLine()) is not null) + Packages.AddRange(ParseInstalledPackages(Manager, ReadOutputLines(p, logger))); + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + + return Packages; + } + + internal static IReadOnlyList ParseInstalledPackages( + WinGet manager, + IEnumerable outputLines + ) + { + List packages = []; + string previousLine = ""; + WinGetTableLayout? layout = null; + + foreach (string line in outputLines) { try { - logger.AddToStdOut(line); - if (!DashesPassed && line.Contains("---")) + if (WinGetTableLayout.IsSeparatorLine(line)) { - string HeaderPrefix = OldLine.Contains("SearchId") ? "Search" : ""; - string HeaderSuffix = OldLine.Contains("SearchId") ? "Header" : ""; - IdIndex = OldLine.IndexOf( - HeaderPrefix + "Id", - StringComparison.InvariantCulture - ); - VersionIndex = OldLine.IndexOf( - HeaderPrefix + "Version", - StringComparison.InvariantCulture - ); - NewVersionIndex = OldLine.IndexOf( - "Available" + HeaderSuffix, - StringComparison.InvariantCulture - ); - SourceIndex = OldLine.IndexOf( - HeaderPrefix + "Source", - StringComparison.InvariantCulture - ); - DashesPassed = true; + layout = WinGetTableLayout.Parse(previousLine, line); + } + else if (string.IsNullOrWhiteSpace(line)) + { + layout = null; } else if ( - DashesPassed - && IdIndex > 0 - && VersionIndex > 0 - && IdIndex < VersionIndex - && VersionIndex < line.Length + layout is not null + && layout.IsRowReaching(line, WinGetTableLayout.VersionColumn) ) { - int offset = 0; // Account for non-unicode character length - while ( - ((IdIndex - offset) <= line.Length && line[IdIndex - offset - 1] != ' ') - || offset > (IdIndex - 5) - ) - { - offset++; - } + string name = layout.GetCell(line, WinGetTableLayout.NameColumn); + string id = layout.GetCell(line, WinGetTableLayout.IdColumn); + string version = layout.GetCell(line, WinGetTableLayout.VersionColumn); - string name = line[..(IdIndex - offset)].Trim(); - string id = line[(IdIndex - offset)..].Trim().Split(' ')[0]; - if (NewVersionIndex == -1 && SourceIndex != -1) - { - NewVersionIndex = SourceIndex; - } - else if (NewVersionIndex == -1 && SourceIndex == -1) - { - NewVersionIndex = line.Length - 1; - } + string sourceName = + layout.ColumnCount >= 4 ? layout.GetCell(line, layout.LastColumn) : ""; - string version = line[(VersionIndex - offset)..(NewVersionIndex - offset)] - .Trim(); + IManagerSource source = + sourceName.Length == 0 + ? manager.GetLocalSource(id) // Load Winget Local Sources + : manager.SourcesHelper.Factory.GetSourceOrDefault(sourceName); - IManagerSource source; - if (SourceIndex == -1 || (SourceIndex - offset) >= line.Length) - { - source = Manager.GetLocalSource(id); // Load Winget Local Sources - } - else - { - string sourceName = line[(SourceIndex - offset)..] - .Trim() - .Split(' ')[0] - .Trim(); - source = Manager.SourcesHelper.Factory.GetSourceOrDefault(sourceName); - } version = WinGetPkgOperationHelper.ResolveReportedInstalledVersion(id, version); - Packages.Add(new Package(name, id, version, source, Manager)); + packages.Add(new Package(name, id, version, source, manager)); } - OldLine = line; + + previousLine = line; } catch (Exception e) { @@ -310,11 +279,7 @@ public IReadOnlyList GetInstalledPackages_UnSafe() } } - logger.AddToStdErr(p.StandardError.ReadToEnd()); - p.WaitForExit(); - logger.Close(p.ExitCode); - - return Packages; + return packages; } public IReadOnlyList FindPackages_UnSafe(string query) @@ -356,66 +321,58 @@ public IReadOnlyList FindPackages_UnSafe(string query) p.Start(); - string OldLine = ""; - int IdIndex = -1; - int VersionIndex = -1; - int SourceIndex = -1; - bool DashesPassed = false; - string? line; - while ((line = p.StandardOutput.ReadLine()) is not null) + Packages.AddRange(ParseFoundPackages(Manager, ReadOutputLines(p, logger))); + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + + return Packages; + } + + internal static IReadOnlyList ParseFoundPackages( + WinGet manager, + IEnumerable outputLines + ) + { + List packages = []; + string previousLine = ""; + WinGetTableLayout? layout = null; + + foreach (string line in outputLines) { - logger.AddToStdOut(line); - if (!DashesPassed && line.Contains("---")) + if (WinGetTableLayout.IsSeparatorLine(line)) + { + layout = WinGetTableLayout.Parse(previousLine, line); + } + else if (string.IsNullOrWhiteSpace(line)) { - string HeaderPrefix = OldLine.Contains("SearchId") ? "Search" : ""; - IdIndex = OldLine.IndexOf(HeaderPrefix + "Id", StringComparison.InvariantCulture); - VersionIndex = OldLine.IndexOf( - HeaderPrefix + "Version", - StringComparison.InvariantCulture - ); - SourceIndex = OldLine.IndexOf( - HeaderPrefix + "Source", - StringComparison.InvariantCulture - ); - DashesPassed = true; + layout = null; } else if ( - DashesPassed - && IdIndex > 0 - && VersionIndex > 0 - && IdIndex < VersionIndex - && VersionIndex < line.Length + layout is not null + && layout.IsRowReaching(line, WinGetTableLayout.VersionColumn) ) { - int offset = 0; // Account for non-unicode character length - while (line[IdIndex - offset - 1] != ' ' || offset > (IdIndex - 5)) - { - offset++; - } + string name = layout.GetCell(line, WinGetTableLayout.NameColumn); + string id = layout.GetCell(line, WinGetTableLayout.IdColumn); + string version = layout.GetCell(line, WinGetTableLayout.VersionColumn); - string name = line[..(IdIndex - offset)].Trim(); - string id = line[(IdIndex - offset)..].Trim().Split(' ')[0]; - string version = line[(VersionIndex - offset)..].Trim().Split(' ')[0]; - IManagerSource source; - if (SourceIndex == -1 || SourceIndex >= line.Length) - { - source = Manager.DefaultSource; - } - else - { - string sourceName = line[(SourceIndex - offset)..].Trim().Split(' ')[0]; - source = Manager.SourcesHelper.Factory.GetSourceOrDefault(sourceName); - } - Packages.Add(new Package(name, id, version, source, Manager)); + string sourceName = + layout.ColumnCount >= 4 ? layout.GetCell(line, layout.LastColumn) : ""; + + IManagerSource source = + sourceName.Length == 0 + ? manager.DefaultSource + : manager.SourcesHelper.Factory.GetSourceOrDefault(sourceName); + + packages.Add(new Package(name, id, version, source, manager)); } - OldLine = line; - } - logger.AddToStdErr(p.StandardError.ReadToEnd()); - p.WaitForExit(); - logger.Close(p.ExitCode); + previousLine = line; + } - return Packages; + return packages; } public void GetPackageDetails_UnSafe(IPackageDetails details) diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs new file mode 100644 index 0000000000..5fc4ecc6c9 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs @@ -0,0 +1,199 @@ +using System.Globalization; + +namespace UniGetUI.PackageEngine.Managers.WingetManager; + +internal sealed class WinGetTableLayout +{ + public const int NameColumn = 0; + public const int IdColumn = 1; + public const int VersionColumn = 2; + public const int AvailableColumn = 3; + + private readonly int[] _columnStarts; + private readonly int _tableWidth; + + private WinGetTableLayout(int[] columnStarts, int tableWidth) + { + _columnStarts = columnStarts; + _tableWidth = tableWidth; + } + + public int ColumnCount => _columnStarts.Length; + + public int LastColumn => _columnStarts.Length - 1; + + public static bool IsSeparatorLine(string line) + { + int dashes = 0; + foreach (char character in line) + { + if (character == '-') + { + dashes++; + } + else if (character != ' ') + { + return false; + } + } + + return dashes >= 3; + } + + public static WinGetTableLayout? Parse(string headerLine, string separatorLine) + { + List columnStarts = []; + bool previousWasSpace = true; + int displayColumn = 0; + int index = 0; + + while (index < headerLine.Length) + { + int length = CodePointLength(headerLine, index); + int codePoint = + length == 2 + ? char.ConvertToUtf32(headerLine[index], headerLine[index + 1]) + : headerLine[index]; + bool isSpace = codePoint == ' '; + + if (!isSpace && previousWasSpace) + { + columnStarts.Add(displayColumn); + } + + previousWasSpace = isSpace; + displayColumn += GetDisplayWidth(codePoint); + index += length; + } + + return columnStarts.Count >= 3 + ? new WinGetTableLayout([.. columnStarts], separatorLine.TrimEnd().Length) + : null; + } + + public bool IsRowReaching(string line, int column) + { + if (column < 0 || column >= _columnStarts.Length) + { + return false; + } + + if (DisplayWidth(line) > _tableWidth) + { + return false; + } + + return CharIndexOfColumn(line, _columnStarts[column]) < line.Length; + } + + public string GetCell(string line, int column) => GetCell(line, column, column + 1); + + public string GetCell(string line, int firstColumn, int columnAfterLast) + { + if (firstColumn < 0 || firstColumn >= _columnStarts.Length) + { + return ""; + } + + int start = CharIndexOfColumn(line, _columnStarts[firstColumn]); + if (start >= line.Length) + { + return ""; + } + + int end = + columnAfterLast < _columnStarts.Length + ? CharIndexOfColumn(line, _columnStarts[columnAfterLast]) + : line.Length; + + if (end > line.Length) + { + end = line.Length; + } + + return end <= start ? "" : line[start..end].Trim(); + } + + private static int DisplayWidth(string line) + { + int width = 0; + int index = 0; + + while (index < line.Length) + { + int length = CodePointLength(line, index); + int codePoint = + length == 2 ? char.ConvertToUtf32(line[index], line[index + 1]) : line[index]; + width += GetDisplayWidth(codePoint); + index += length; + } + + return width; + } + + private static int CharIndexOfColumn(string line, int displayColumn) + { + int index = 0; + int width = 0; + + while (index < line.Length && width < displayColumn) + { + int length = CodePointLength(line, index); + int codePoint = + length == 2 ? char.ConvertToUtf32(line[index], line[index + 1]) : line[index]; + width += GetDisplayWidth(codePoint); + index += length; + } + + while (index > 0 && index < line.Length && line[index] != ' ' && line[index - 1] != ' ') + { + index--; + } + + return index; + } + + private static int CodePointLength(string text, int index) => + char.IsHighSurrogate(text[index]) + && index + 1 < text.Length + && char.IsLowSurrogate(text[index + 1]) + ? 2 + : 1; + + private static int GetDisplayWidth(int codePoint) + { + UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory(codePoint); + if ( + category + is UnicodeCategory.NonSpacingMark + or UnicodeCategory.EnclosingMark + or UnicodeCategory.Format + or UnicodeCategory.Control + ) + { + return 0; + } + + return IsFullWidth(codePoint) ? 2 : 1; + } + + private static bool IsFullWidth(int codePoint) => + codePoint + is >= 0x1100 and <= 0x115F + or >= 0x2E80 and <= 0x303E + or >= 0x3041 and <= 0x33FF + or >= 0x3400 and <= 0x4DBF + or >= 0x4E00 and <= 0x9FFF + or >= 0xA000 and <= 0xA4CF + or >= 0xA960 and <= 0xA97F + or >= 0xAC00 and <= 0xD7A3 + or >= 0xF900 and <= 0xFAFF + or >= 0xFE10 and <= 0xFE19 + or >= 0xFE30 and <= 0xFE6F + or >= 0xFF00 and <= 0xFF60 + or >= 0xFFE0 and <= 0xFFE6 + or >= 0x17000 and <= 0x18CFF + or >= 0x1B000 and <= 0x1B2FF + or >= 0x20000 and <= 0x2FFFD + or >= 0x30000 and <= 0x3FFFD; +} diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs new file mode 100644 index 0000000000..27a2b794f0 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs @@ -0,0 +1,532 @@ +#if WINDOWS +using UniGetUI.Core.Data; +using UniGetUI.PackageEngine.Managers.WingetManager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(WinGetManagerTestCollection.Name)] +public sealed class WinGetCliParsingTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + AppContext.BaseDirectory, + "WinGetCliParsingTests", + Guid.NewGuid().ToString("N") + ); + + public WinGetCliParsingTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + } + + public void Dispose() + { + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + private static string[] Lines(string output) => + output.Replace("\r\n", "\n").Split('\n', StringSplitOptions.None); + + [Fact] + public void ParseInstalledPackagesReadsJapaneseLocalizedTable() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + 名前 ID バージョン 利用可能 ソース + ----------------------------------------------------------------------------------------------------------------------------- + 7-Zip 24.09 (x64) 7zip.7zip 24.09 25.00 winget + サクラエディタ SakuraEditor.SakuraEditor 2.4.2 winget + Copilot ARP\Machine\X86\Microsoft Copilot 152.0.4191.66 + Xbox Identity Provider MSIX\Microsoft.XboxIdentityProvider_12.130.16001.0_arm64__8wekyb3d8bbwe 12.130.16001.0 + 利用可能なアップグレードが 1 件あります。 + """ + ) + ); + + Assert.Equal(4, packages.Count); + PackageAssert.Matches(packages[0], "7-Zip 24.09 (x64)", "7zip.7zip", "24.09"); + PackageAssert.Matches( + packages[1], + "サクラエディタ", + "SakuraEditor.SakuraEditor", + "2.4.2" + ); + PackageAssert.Matches( + packages[2], + "Copilot", + @"ARP\Machine\X86\Microsoft Copilot", + "152.0.4191.66" + ); + PackageAssert.Matches( + packages[3], + "Xbox Identity Provider", + @"MSIX\Microsoft.XboxIdentityProvider_12.130.16001.0_arm64__8wekyb3d8bbwe", + "12.130.16001.0" + ); + + Assert.Equal("winget", packages[0].Source.Name); + Assert.Same(manager.LocalPcSource, packages[2].Source); + Assert.Same(manager.MicrosoftStoreSource, packages[3].Source); + } + + [Fact] + public void ParseInstalledPackagesReadsEnglishTable() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + Name Id Version Available Source + ------------------------------------------------------------------------------------------------------------------------------ + 7-Zip 24.09 (x64) 7zip.7zip 24.09 25.00 winget + Copilot ARP\Machine\X86\Microsoft Copilot 152.0.4191.66 + Xbox Identity Provider MSIX\Microsoft.XboxIdentityProvider_12.130.16001.0_arm64__8wekyb3d8bbwe 12.130.16001.0 + """ + ) + ); + + Assert.Equal(3, packages.Count); + PackageAssert.Matches(packages[0], "7-Zip 24.09 (x64)", "7zip.7zip", "24.09"); + PackageAssert.Matches( + packages[1], + "Copilot", + @"ARP\Machine\X86\Microsoft Copilot", + "152.0.4191.66" + ); + Assert.Same(manager.MicrosoftStoreSource, packages[2].Source); + } + + [Fact] + public void ParseInstalledPackagesReadsUntranslatedResourceKeyHeaders() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + SearchName SearchId SearchVersion AvailableHeader SearchSource + ---------------------------------------------------------------------- + 7-Zip 24.09 (x64) 7zip.7zip 24.09 25.00 winget + """ + ) + ); + + PackageAssert.Matches( + Assert.Single(packages), + "7-Zip 24.09 (x64)", + "7zip.7zip", + "24.09" + ); + } + + [Fact] + public void ParseAvailableUpdatesReadsJapaneseLocalizedTable() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseAvailableUpdates( + manager, + Lines( + """ + 名前 ID バージョン 利用可能 ソース + ---------------------------------------------------------------------- + 7-Zip 24.09 (x64) 7zip.7zip 24.09 25.00 winget + サクラエディタ SakuraEditor.SakuraEditor 2.4.2 2.4.3 winget + 2 個のアップグレードが利用可能です。 + """ + ) + ); + + Assert.Equal(2, packages.Count); + PackageAssert.Matches(packages[0], "7-Zip 24.09 (x64)", "7zip.7zip", "24.09", "25.00"); + PackageAssert.Matches( + packages[1], + "サクラエディタ", + "SakuraEditor.SakuraEditor", + "2.4.2", + "2.4.3" + ); + } + + [Fact] + public void ParseAvailableUpdatesReadsHeadersWhoseColumnNameContainsASpace() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseAvailableUpdates( + manager, + Lines( + """ + 이름 ID 버전 사용 가능 소스 + ------------------------------------------------------------ + 7-Zip 24.09 (x64) 7zip.7zip 24.09 25.00 winget + 메모장 Notepad++.Notepad++ 8.9.7 8.9.8 winget + 사용 가능한 업그레이드 2개 + """ + ) + ); + + Assert.Equal(2, packages.Count); + PackageAssert.Matches(packages[0], "7-Zip 24.09 (x64)", "7zip.7zip", "24.09", "25.00"); + PackageAssert.Matches(packages[1], "메모장", "Notepad++.Notepad++", "8.9.7", "8.9.8"); + Assert.Equal("winget", packages[1].Source.Name); + } + + [Fact] + public void ParseAvailableUpdatesKeepsSingleSpacedColumns() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseAvailableUpdates( + manager, + Lines( + """ + Name Id Version Available Source + ---------------------------------------------------- + Claude Anthropic.Claude 1.24012.0.0 1.44121.2 winget + 1 upgrades available. + """ + ) + ); + + PackageAssert.Matches( + Assert.Single(packages), + "Claude", + "Anthropic.Claude", + "1.24012.0.0", + "1.44121.2" + ); + } + + [Fact] + public void ParseFoundPackagesReadsJapaneseLocalizedTableWithMatchColumn() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseFoundPackages( + manager, + Lines( + """ + 名前 ID バージョン 一致 ソース + -------------------------------------------------------------------------------------------- + Microsoft Visual Studio Code Microsoft.VisualStudioCode 1.136.1 モニカー: vscode winget + Codium Alex313031.Codium 1.93.1.24277 タグ: vscode winget + """ + ) + ); + + Assert.Equal(2, packages.Count); + PackageAssert.Matches( + packages[0], + "Microsoft Visual Studio Code", + "Microsoft.VisualStudioCode", + "1.136.1" + ); + PackageAssert.Matches(packages[1], "Codium", "Alex313031.Codium", "1.93.1.24277"); + Assert.Equal("winget", packages[0].Source.Name); + } + + [Fact] + public void ParseFoundPackagesReadsRowsWithAnEmptyMatchColumn() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseFoundPackages( + manager, + Lines( + """ + Name Id Version Match Source + ------------------------------------------------------------------------------- + VLC XPDM1ZW6815MQM Unknown msstore + VLC UWP 9NBLGGH4VVNH Unknown msstore + VLC media player VideoLAN.VLC 3.0.21 Moniker: vlc winget + """ + ) + ); + + Assert.Equal(3, packages.Count); + PackageAssert.Matches(packages[0], "VLC", "XPDM1ZW6815MQM", "Unknown"); + Assert.Equal("msstore", packages[0].Source.Name); + PackageAssert.Matches(packages[2], "VLC media player", "VideoLAN.VLC", "3.0.21"); + Assert.Equal("winget", packages[2].Source.Name); + } + + [Fact] + public void ParseInstalledPackagesReadsCapturedJapaneseWinGetOutput() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + 名前 ID バージョン + --------------------------------------------------------------------------------------------- + インテル® グラフィックス・コマンド・… AppUp.IntelGraphicsExperience_8j3eq9… 1.100.3370.0 + Ubuntu CanonicalGroupLimited.UbuntuonWindow… 2004.2021.222.0 + CrystalDiskMark CrystalDewWorld.CrystalDiskMark 8.0.4 + Docker Desktop Docker.DockerDesktop 3.5.2 + ELAN Touchpad 12.11.3.2_X64_Beta Elantech 12.11.3.2 + Microsoft Edge Microsoft.Edge 92.0.902.55 + Microsoft Edge Update Microsoft Edge Update 1.3.145.49 + """ + ) + ); + + Assert.Equal(7, packages.Count); + PackageAssert.Matches( + packages[0], + "インテル® グラフィックス・コマンド・…", + "AppUp.IntelGraphicsExperience_8j3eq9…", + "1.100.3370.0" + ); + PackageAssert.Matches( + packages[1], + "Ubuntu", + "CanonicalGroupLimited.UbuntuonWindow…", + "2004.2021.222.0" + ); + PackageAssert.Matches( + packages[4], + "ELAN Touchpad 12.11.3.2_X64_Beta", + "Elantech", + "12.11.3.2" + ); + PackageAssert.Matches( + packages[6], + "Microsoft Edge Update", + "Microsoft Edge Update", + "1.3.145.49" + ); + Assert.Same(manager.LocalPcSource, packages[6].Source); + } + + [Fact] + public void ParseAvailableUpdatesReadsCapturedJapaneseWinGetOutput() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseAvailableUpdates( + manager, + Lines( + """ + 名前 ID バージョン 利用可能 ソース + -------------------------------------------------------------------------- + CrystalDiskInfo CrystalDewWorld.CrystalDiskInfo 8.6.1 8.12.4 winget + CrystalDiskMark CrystalDewWorld.CrystalDiskMark 8.0.2 8.0.4 winget + """ + ) + ); + + Assert.Equal(2, packages.Count); + PackageAssert.Matches( + packages[0], + "CrystalDiskInfo", + "CrystalDewWorld.CrystalDiskInfo", + "8.6.1", + "8.12.4" + ); + PackageAssert.Matches( + packages[1], + "CrystalDiskMark", + "CrystalDewWorld.CrystalDiskMark", + "8.0.2", + "8.0.4" + ); + Assert.Equal("winget", packages[0].Source.Name); + } + + [Fact] + public void ParseAvailableUpdatesReadsCjkPackageNamesUnderEnglishHeaders() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseAvailableUpdates( + manager, + Lines( + """ + Name Id Version Available Source + --------------------------------------------------------------------------- + Google 日本語入力 Google.JapaneseIME 2.31.5840.0 2.32.5990.0 winget + Zoom Workplace (64-bit) Zoom.Zoom 6.6.19369 6.6.19875 winget + Dropbox Dropbox.Dropbox 235.4.5905 236.3.5770 winget + OBS Studio OBSProject.OBSStudio 32.0.1 32.0.2 winget + 4 upgrades available. + """ + ) + ); + + Assert.Equal(4, packages.Count); + PackageAssert.Matches( + packages[0], + "Google 日本語入力", + "Google.JapaneseIME", + "2.31.5840.0", + "2.32.5990.0" + ); + PackageAssert.Matches( + packages[3], + "OBS Studio", + "OBSProject.OBSStudio", + "32.0.1", + "32.0.2" + ); + } + + [Fact] + public void ParseInstalledPackagesReadsTableWithoutAnAvailableColumn() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + Name Id Version Source + -------------------------------------------------- + 7-Zip 24.09 (x64) 7zip.7zip 24.09 winget + Contoso Tool Programs\Contoso 1.0.0 + """ + ) + ); + + Assert.Equal(2, packages.Count); + PackageAssert.Matches(packages[0], "7-Zip 24.09 (x64)", "7zip.7zip", "24.09"); + Assert.Equal("winget", packages[0].Source.Name); + PackageAssert.Matches(packages[1], "Contoso Tool", @"Programs\Contoso", "1.0.0"); + Assert.Same(manager.LocalPcSource, packages[1].Source); + } + + [Fact] + public void ParseInstalledPackagesTreatsAPaddedBlankSourceCellAsLocal() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + "Name Id Version Source\n" + + "--------------------------------------------------\n" + + "Contoso Tool Programs\\Contoso 1.0.0 \n" + ) + ); + + Assert.Same(manager.LocalPcSource, Assert.Single(packages).Source); + } + + [Fact] + public void ParseAvailableUpdatesReadsASecondTableWithoutASourceColumn() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseAvailableUpdates( + manager, + Lines( + """ + Name Id Version Available Source + ---------------------------------------------------- + 7-Zip 24.09 (x64) 7zip.7zip 24.09 25.00 winget + 1 upgrades available. + + The following packages have an upgrade available, but require explicit targeting for upgrade: + Name Id Version Available + ------------------------------------------------------ + Fabrikam Widget Fabrikam.Widget 4.1.0 4.2.0 + """ + ) + ); + + Assert.Equal(2, packages.Count); + PackageAssert.Matches(packages[0], "7-Zip 24.09 (x64)", "7zip.7zip", "24.09", "25.00"); + Assert.Equal("winget", packages[0].Source.Name); + PackageAssert.Matches(packages[1], "Fabrikam Widget", "Fabrikam.Widget", "4.1.0", "4.2.0"); + Assert.Same(manager.DefaultSource, packages[1].Source); + } + + [Fact] + public void ParseAvailableUpdatesIgnoresLocalizedTrailingMessages() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseAvailableUpdates( + manager, + Lines( + """ + 名前 ID バージョン 利用可能 ソース + -------------------------------------------------------------------------- + CrystalDiskInfo CrystalDewWorld.CrystalDiskInfo 8.6.1 8.12.4 winget + 1 個のパッケージにはアップグレードを妨げるピンが設定されています。'winget pin' コマンドを使用してピンを表示および編集してください。'--include-pinned' 引数を使用すると、さらに多くの結果が表示される場合があります。 + """ + ) + ); + + PackageAssert.Matches( + Assert.Single(packages), + "CrystalDiskInfo", + "CrystalDewWorld.CrystalDiskInfo", + "8.6.1", + "8.12.4" + ); + } + + [Fact] + public void ParseInstalledPackagesRecoversFromUnderestimatedCharacterWidths() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + Name Id Version + ---------------------------- + 🎮 Game Contoso.Game 1.0.0 + """ + ) + ); + + PackageAssert.Matches(Assert.Single(packages), "🎮 Game", "Contoso.Game", "1.0.0"); + } + + [Fact] + public void ParseInstalledPackagesIgnoresOutputWithoutATable() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + インストールされたパッケージが見つかりませんでした。 + """ + ) + ); + + Assert.Empty(packages); + } + + [Theory] + [InlineData("-------------------------", true)] + [InlineData(" ------ ", true)] + [InlineData("--", false)] + [InlineData("7-Zip 24.09 (x64) 7zip.7zip 24.09", false)] + [InlineData("", false)] + public void IsSeparatorLineOnlyAcceptsDashRuns(string line, bool expected) + { + Assert.Equal(expected, WinGetTableLayout.IsSeparatorLine(line)); + } +} +#endif From 1b02361f1164a2ed9c6115539d67c9a7e7e73b97 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 8 Sep 2026 09:56:23 -0400 Subject: [PATCH 2/6] Measure grapheme clusters and resolve the optional Source column per row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found in PR review of the locale-independent table parser. WinGet's UTF8ColumnWidth walks the string with an ICU UBRK_CHARACTER break iterator and classifies only the first code point of each grapheme cluster, adding 2 for East Asian Wide/Fullwidth and 1 for everything else. Measuring per code point instead counted a ZWJ sequence such as the family emoji once per component, overshooting a cell WinGet renders as two columns; the name was then cut at the wrong boundary, and a row that fits WinGet's separator could be rejected as too wide. Iterate text elements and take the width of the cluster's first code point, in the header, the row width and the column index alike. The zero-width category check goes with it: ICU never yields a zero-width cluster, and a combining sequence is already folded into the cluster of its base character. The trailing Source column was inferred from the number of header tokens, which does not establish that the column exists. WinGet omits a Source column whose values are all empty, while a locale that renders the Available header as two words (Korean "사용 가능") contributes two starts of its own, so a source-less table still records five. An available version longer than the gap to the second word then straddles the phantom boundary: both ends snap back to the value's start, the available version comes out empty and the version text is registered as a source name. Resolve the column against each row instead - it is real only when its boundary lands past the preceding column's on that row - which also covers the case where the row simply stops before an empty Source cell. Neither change alters what the parser produces for any of the captured English or Japanese output: all 218 rows across the seven fixtures parse byte-identically before and after. Co-Authored-By: Claude Opus 5 (1M context) --- .../ClientHelpers/WinGetCliHelper.cs | 15 +++- .../ClientHelpers/WinGetTableLayout.cs | 59 +++++++--------- .../WinGetCliParsingTests.cs | 70 +++++++++++++++++++ 3 files changed, 106 insertions(+), 38 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs index ff5649e5e3..25ffcc66ce 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs @@ -131,7 +131,10 @@ layout is not null string newVersion; IManagerSource source; - if (layout.ColumnCount >= 5) + if ( + layout.ColumnCount >= 5 + && layout.StartsSeparateCell(line, layout.LastColumn) + ) { newVersion = layout.GetCell( line, @@ -260,7 +263,10 @@ layout is not null string version = layout.GetCell(line, WinGetTableLayout.VersionColumn); string sourceName = - layout.ColumnCount >= 4 ? layout.GetCell(line, layout.LastColumn) : ""; + layout.ColumnCount >= 4 + && layout.StartsSeparateCell(line, layout.LastColumn) + ? layout.GetCell(line, layout.LastColumn) + : ""; IManagerSource source = sourceName.Length == 0 @@ -359,7 +365,10 @@ layout is not null string version = layout.GetCell(line, WinGetTableLayout.VersionColumn); string sourceName = - layout.ColumnCount >= 4 ? layout.GetCell(line, layout.LastColumn) : ""; + layout.ColumnCount >= 4 + && layout.StartsSeparateCell(line, layout.LastColumn) + ? layout.GetCell(line, layout.LastColumn) + : ""; IManagerSource source = sourceName.Length == 0 diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs index 5fc4ecc6c9..06a702b16f 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs @@ -49,11 +49,7 @@ public static bool IsSeparatorLine(string line) while (index < headerLine.Length) { - int length = CodePointLength(headerLine, index); - int codePoint = - length == 2 - ? char.ConvertToUtf32(headerLine[index], headerLine[index + 1]) - : headerLine[index]; + int codePoint = FirstCodePoint(headerLine, index); bool isSpace = codePoint == ' '; if (!isSpace && previousWasSpace) @@ -63,7 +59,7 @@ public static bool IsSeparatorLine(string line) previousWasSpace = isSpace; displayColumn += GetDisplayWidth(codePoint); - index += length; + index += TextElementLength(headerLine, index); } return columnStarts.Count >= 3 @@ -86,6 +82,17 @@ public bool IsRowReaching(string line, int column) return CharIndexOfColumn(line, _columnStarts[column]) < line.Length; } + public bool StartsSeparateCell(string line, int column) + { + if (column <= 0 || column >= _columnStarts.Length) + { + return false; + } + + return CharIndexOfColumn(line, _columnStarts[column]) + > CharIndexOfColumn(line, _columnStarts[column - 1]); + } + public string GetCell(string line, int column) => GetCell(line, column, column + 1); public string GetCell(string line, int firstColumn, int columnAfterLast) @@ -121,11 +128,8 @@ private static int DisplayWidth(string line) while (index < line.Length) { - int length = CodePointLength(line, index); - int codePoint = - length == 2 ? char.ConvertToUtf32(line[index], line[index + 1]) : line[index]; - width += GetDisplayWidth(codePoint); - index += length; + width += GetDisplayWidth(FirstCodePoint(line, index)); + index += TextElementLength(line, index); } return width; @@ -138,11 +142,8 @@ private static int CharIndexOfColumn(string line, int displayColumn) while (index < line.Length && width < displayColumn) { - int length = CodePointLength(line, index); - int codePoint = - length == 2 ? char.ConvertToUtf32(line[index], line[index + 1]) : line[index]; - width += GetDisplayWidth(codePoint); - index += length; + width += GetDisplayWidth(FirstCodePoint(line, index)); + index += TextElementLength(line, index); } while (index > 0 && index < line.Length && line[index] != ' ' && line[index - 1] != ' ') @@ -153,29 +154,17 @@ private static int CharIndexOfColumn(string line, int displayColumn) return index; } - private static int CodePointLength(string text, int index) => + private static int TextElementLength(string text, int index) => + Math.Max(1, StringInfo.GetNextTextElementLength(text.AsSpan(index))); + + private static int FirstCodePoint(string text, int index) => char.IsHighSurrogate(text[index]) && index + 1 < text.Length && char.IsLowSurrogate(text[index + 1]) - ? 2 - : 1; - - private static int GetDisplayWidth(int codePoint) - { - UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory(codePoint); - if ( - category - is UnicodeCategory.NonSpacingMark - or UnicodeCategory.EnclosingMark - or UnicodeCategory.Format - or UnicodeCategory.Control - ) - { - return 0; - } + ? char.ConvertToUtf32(text[index], text[index + 1]) + : text[index]; - return IsFullWidth(codePoint) ? 2 : 1; - } + private static int GetDisplayWidth(int codePoint) => IsFullWidth(codePoint) ? 2 : 1; private static bool IsFullWidth(int codePoint) => codePoint diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs index 27a2b794f0..0755941653 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs @@ -501,6 +501,76 @@ Name Id Version PackageAssert.Matches(Assert.Single(packages), "🎮 Game", "Contoso.Game", "1.0.0"); } + [Fact] + public void ParseInstalledPackagesMeasuresGraphemeClustersAsOneCell() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + Name Id Version + -------------------------------- + 👨‍👩‍👧‍👦 Family Contoso.Family 1.0.0 + """ + ) + ); + + PackageAssert.Matches( + Assert.Single(packages), + "👨‍👩‍👧‍👦 Family", + "Contoso.Family", + "1.0.0" + ); + } + + [Fact] + public void ParseAvailableUpdatesHandlesAMultiwordHeaderWithoutASourceColumn() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseAvailableUpdates( + manager, + Lines( + """ + 이름 ID 버전 사용 가능 + --------------------------------------------- + 7-Zip 24.09 (x64) 7zip.7zip 24.09 2026.2.16.0 + """ + ) + ); + + PackageAssert.Matches( + Assert.Single(packages), + "7-Zip 24.09 (x64)", + "7zip.7zip", + "24.09", + "2026.2.16.0" + ); + Assert.Same(manager.DefaultSource, packages[0].Source); + } + + [Fact] + public void ParseInstalledPackagesHandlesAMultiwordHeaderWithoutASourceColumn() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + 이름 ID 버전 사용 가능 + --------------------------------------------- + 7-Zip 24.09 (x64) 7zip.7zip 24.09 2026.2.16.0 + """ + ) + ); + + PackageAssert.Matches(Assert.Single(packages), "7-Zip 24.09 (x64)", "7zip.7zip", "24.09"); + Assert.Same(manager.LocalPcSource, packages[0].Source); + } + [Fact] public void ParseInstalledPackagesIgnoresOutputWithoutATable() { From f119c9cd5ce1efea273f4108eab66c45d436f821 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 8 Sep 2026 11:12:52 -0400 Subject: [PATCH 3/6] Derive table columns from the rows, not from the header alone --- .../ClientHelpers/WinGetCliHelper.cs | 97 ++----- .../ClientHelpers/WinGetTableLayout.cs | 248 +++++++++++++++--- .../WinGetCliParsingTests.cs | 142 +++++++++- 3 files changed, 386 insertions(+), 101 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs index 25ffcc66ce..86e742b4f5 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs @@ -101,40 +101,33 @@ IEnumerable outputLines ) { List packages = []; - string previousLine = ""; - WinGetTableLayout? layout = null; - foreach (string line in outputLines) + foreach ( + WinGetTable table in WinGetTableLayout.ReadTables( + outputLines.Where(line => !line.Contains("have pins")) + ) + ) { - if (line.Contains("have pins")) + WinGetTableLayout layout = table.Layout; + if (layout.ColumnCount < 4) { continue; } - if (WinGetTableLayout.IsSeparatorLine(line)) - { - layout = WinGetTableLayout.Parse(previousLine, line); - } - else if (string.IsNullOrWhiteSpace(line)) - { - layout = null; - } - else if ( - layout is not null - && layout.ColumnCount >= 4 - && layout.IsRowReaching(line, WinGetTableLayout.AvailableColumn) - ) + foreach (string line in table.Rows) { + if (!layout.IsRowReaching(line, WinGetTableLayout.AvailableColumn)) + { + continue; + } + string name = layout.GetCell(line, WinGetTableLayout.NameColumn); string id = layout.GetCell(line, WinGetTableLayout.IdColumn); string version = layout.GetCell(line, WinGetTableLayout.VersionColumn); string newVersion; IManagerSource source; - if ( - layout.ColumnCount >= 5 - && layout.StartsSeparateCell(line, layout.LastColumn) - ) + if (layout.HasSourceColumn) { newVersion = layout.GetCell( line, @@ -178,8 +171,6 @@ layout is not null ); } } - - previousLine = line; } return packages; @@ -238,33 +229,20 @@ IEnumerable outputLines ) { List packages = []; - string previousLine = ""; - WinGetTableLayout? layout = null; - foreach (string line in outputLines) + foreach (WinGetTable table in WinGetTableLayout.ReadTables(outputLines)) { - try + WinGetTableLayout layout = table.Layout; + foreach (string line in table.Rows) { - if (WinGetTableLayout.IsSeparatorLine(line)) - { - layout = WinGetTableLayout.Parse(previousLine, line); - } - else if (string.IsNullOrWhiteSpace(line)) - { - layout = null; - } - else if ( - layout is not null - && layout.IsRowReaching(line, WinGetTableLayout.VersionColumn) - ) + try { string name = layout.GetCell(line, WinGetTableLayout.NameColumn); string id = layout.GetCell(line, WinGetTableLayout.IdColumn); string version = layout.GetCell(line, WinGetTableLayout.VersionColumn); string sourceName = - layout.ColumnCount >= 4 - && layout.StartsSeparateCell(line, layout.LastColumn) + layout.HasSourceColumn ? layout.GetCell(line, layout.LastColumn) : ""; @@ -276,12 +254,10 @@ layout is not null version = WinGetPkgOperationHelper.ResolveReportedInstalledVersion(id, version); packages.Add(new Package(name, id, version, source, manager)); } - - previousLine = line; - } - catch (Exception e) - { - Logger.Error(e); + catch (Exception e) + { + Logger.Error(e); + } } } @@ -342,33 +318,20 @@ IEnumerable outputLines ) { List packages = []; - string previousLine = ""; - WinGetTableLayout? layout = null; - foreach (string line in outputLines) + foreach (WinGetTable table in WinGetTableLayout.ReadTables(outputLines)) { - if (WinGetTableLayout.IsSeparatorLine(line)) - { - layout = WinGetTableLayout.Parse(previousLine, line); - } - else if (string.IsNullOrWhiteSpace(line)) - { - layout = null; - } - else if ( - layout is not null - && layout.IsRowReaching(line, WinGetTableLayout.VersionColumn) - ) + WinGetTableLayout layout = table.Layout; + foreach (string line in table.Rows) { string name = layout.GetCell(line, WinGetTableLayout.NameColumn); string id = layout.GetCell(line, WinGetTableLayout.IdColumn); string version = layout.GetCell(line, WinGetTableLayout.VersionColumn); string sourceName = - layout.ColumnCount >= 4 - && layout.StartsSeparateCell(line, layout.LastColumn) - ? layout.GetCell(line, layout.LastColumn) - : ""; + layout.HasSourceColumn + ? layout.GetCell(line, layout.LastColumn) + : ""; IManagerSource source = sourceName.Length == 0 @@ -377,8 +340,6 @@ layout is not null packages.Add(new Package(name, id, version, source, manager)); } - - previousLine = line; } return packages; diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs index 06a702b16f..3a1a300c13 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs @@ -1,7 +1,10 @@ using System.Globalization; +using System.Text; namespace UniGetUI.PackageEngine.Managers.WingetManager; +internal sealed record WinGetTable(WinGetTableLayout Layout, IReadOnlyList Rows); + internal sealed class WinGetTableLayout { public const int NameColumn = 0; @@ -9,11 +12,43 @@ internal sealed class WinGetTableLayout public const int VersionColumn = 2; public const int AvailableColumn = 3; + private const int MinimumColumns = 3; + + private static readonly HashSet AvailableOrMatchHeaders = new( + StringComparer.OrdinalIgnoreCase + ) + { + "Available", + "AvailableHeader", + "Coincidencia", + "Correspondance", + "Correspondência", + "Corrispondenza", + "Disponibile", + "Disponible", + "Disponível", + "Match", + "SearchMatch", + "Verfügbar", + "Übereinstimmung", + "Доступно", + "Совпадение", + "一致", + "利用可能", + "匹配", + "可用", + "相符", + "사용 가능", + "일치", + }; + + private readonly string _headerLine; private readonly int[] _columnStarts; private readonly int _tableWidth; - private WinGetTableLayout(int[] columnStarts, int tableWidth) + private WinGetTableLayout(string headerLine, int[] columnStarts, int tableWidth) { + _headerLine = headerLine; _columnStarts = columnStarts; _tableWidth = tableWidth; } @@ -22,6 +57,13 @@ private WinGetTableLayout(int[] columnStarts, int tableWidth) public int LastColumn => _columnStarts.Length - 1; + public bool HasSourceColumn => + ColumnCount >= 5 + || ( + ColumnCount == 4 + && !AvailableOrMatchHeaders.Contains(GetCell(_headerLine, LastColumn)) + ); + public static bool IsSeparatorLine(string line) { int dashes = 0; @@ -40,6 +82,55 @@ public static bool IsSeparatorLine(string line) return dashes >= 3; } + public static IEnumerable ReadTables(IEnumerable lines) + { + string previousLine = ""; + WinGetTableLayout? layout = null; + List rows = []; + + foreach (string line in lines) + { + if (IsSeparatorLine(line)) + { + if (layout is not null) + { + yield return BuildTable(layout, rows); + } + + layout = Parse(previousLine, line); + rows = []; + } + else if (string.IsNullOrWhiteSpace(line)) + { + if (layout is not null) + { + yield return BuildTable(layout, rows); + } + + layout = null; + rows = []; + } + else if ( + layout is not null + && layout.IsRowReaching(line, VersionColumn) + && !layout.StraddlesColumn(line, IdColumn) + ) + { + rows.Add(line); + } + + previousLine = line; + } + + if (layout is not null) + { + yield return BuildTable(layout, rows); + } + } + + private static WinGetTable BuildTable(WinGetTableLayout layout, List rows) => + new(layout.MergeContinuationColumns(rows), rows); + public static WinGetTableLayout? Parse(string headerLine, string separatorLine) { List columnStarts = []; @@ -62,11 +153,53 @@ public static bool IsSeparatorLine(string line) index += TextElementLength(headerLine, index); } - return columnStarts.Count >= 3 - ? new WinGetTableLayout([.. columnStarts], separatorLine.TrimEnd().Length) + return columnStarts.Count >= MinimumColumns + ? new WinGetTableLayout(headerLine, [.. columnStarts], separatorLine.TrimEnd().Length) : null; } + public WinGetTableLayout MergeContinuationColumns(IReadOnlyList rows) + { + if (rows.Count == 0 || _columnStarts.Length <= MinimumColumns) + { + return this; + } + + List kept = [_columnStarts[0]]; + int remaining = _columnStarts.Length; + + for (int column = 1; column < _columnStarts.Length; column++) + { + int straddled = 0; + int startsACell = 0; + + foreach (string row in rows) + { + if (Straddles(row, _columnStarts[column])) + { + straddled++; + } + else if (StartsACell(row, _columnStarts[column])) + { + startsACell++; + } + } + + if (straddled > startsACell && remaining - 1 >= MinimumColumns) + { + remaining--; + } + else + { + kept.Add(_columnStarts[column]); + } + } + + return kept.Count == _columnStarts.Length + ? this + : new WinGetTableLayout(_headerLine, [.. kept], _tableWidth); + } + public bool IsRowReaching(string line, int column) { if (column < 0 || column >= _columnStarts.Length) @@ -82,15 +215,21 @@ public bool IsRowReaching(string line, int column) return CharIndexOfColumn(line, _columnStarts[column]) < line.Length; } - public bool StartsSeparateCell(string line, int column) + public bool StraddlesColumn(string line, int column) => + column > 0 + && column < _columnStarts.Length + && Straddles(line, _columnStarts[column]); + + private static bool Straddles(string line, int displayColumn) { - if (column <= 0 || column >= _columnStarts.Length) - { - return false; - } + int index = RawCharIndexOfColumn(line, displayColumn); + return index > 0 && index < line.Length && line[index] != ' ' && line[index - 1] != ' '; + } - return CharIndexOfColumn(line, _columnStarts[column]) - > CharIndexOfColumn(line, _columnStarts[column - 1]); + private static bool StartsACell(string line, int displayColumn) + { + int index = RawCharIndexOfColumn(line, displayColumn); + return index < line.Length && line[index] != ' ' && (index == 0 || line[index - 1] == ' '); } public string GetCell(string line, int column) => GetCell(line, column, column + 1); @@ -123,6 +262,11 @@ public string GetCell(string line, int firstColumn, int columnAfterLast) private static int DisplayWidth(string line) { + if (Ascii.IsValid(line)) + { + return line.Length; + } + int width = 0; int index = 0; @@ -135,8 +279,13 @@ private static int DisplayWidth(string line) return width; } - private static int CharIndexOfColumn(string line, int displayColumn) + private static int RawCharIndexOfColumn(string line, int displayColumn) { + if (Ascii.IsValid(line)) + { + return Math.Min(displayColumn, line.Length); + } + int index = 0; int width = 0; @@ -146,6 +295,13 @@ private static int CharIndexOfColumn(string line, int displayColumn) index += TextElementLength(line, index); } + return index; + } + + private static int CharIndexOfColumn(string line, int displayColumn) + { + int index = RawCharIndexOfColumn(line, displayColumn); + while (index > 0 && index < line.Length && line[index] != ' ' && line[index - 1] != ' ') { index--; @@ -166,23 +322,55 @@ private static int FirstCodePoint(string text, int index) => private static int GetDisplayWidth(int codePoint) => IsFullWidth(codePoint) ? 2 : 1; - private static bool IsFullWidth(int codePoint) => - codePoint - is >= 0x1100 and <= 0x115F - or >= 0x2E80 and <= 0x303E - or >= 0x3041 and <= 0x33FF - or >= 0x3400 and <= 0x4DBF - or >= 0x4E00 and <= 0x9FFF - or >= 0xA000 and <= 0xA4CF - or >= 0xA960 and <= 0xA97F - or >= 0xAC00 and <= 0xD7A3 - or >= 0xF900 and <= 0xFAFF - or >= 0xFE10 and <= 0xFE19 - or >= 0xFE30 and <= 0xFE6F - or >= 0xFF00 and <= 0xFF60 - or >= 0xFFE0 and <= 0xFFE6 - or >= 0x17000 and <= 0x18CFF - or >= 0x1B000 and <= 0x1B2FF - or >= 0x20000 and <= 0x2FFFD - or >= 0x30000 and <= 0x3FFFD; + private static bool IsFullWidth(int codePoint) + { + int index = Array.BinarySearch(WideRangeStarts, codePoint); + if (index >= 0) + { + return true; + } + + index = ~index - 1; + return index >= 0 && codePoint <= WideRangeEnds[index]; + } + + private static readonly int[] WideRangeStarts = + [ + 0x01100, 0x0231A, 0x02329, 0x023E9, 0x023F0, 0x023F3, 0x025FD, 0x02614, + 0x02630, 0x02648, 0x0267F, 0x0268A, 0x02693, 0x026A1, 0x026AA, 0x026BD, + 0x026C4, 0x026CE, 0x026D4, 0x026EA, 0x026F2, 0x026F5, 0x026FA, 0x026FD, + 0x02705, 0x0270A, 0x02728, 0x0274C, 0x0274E, 0x02753, 0x02757, 0x02795, + 0x027B0, 0x027BF, 0x02B1B, 0x02B50, 0x02B55, 0x02E80, 0x02E9B, 0x02F00, + 0x02FF0, 0x03041, 0x03099, 0x03105, 0x03131, 0x03190, 0x031EF, 0x03220, + 0x03250, 0x0A490, 0x0A960, 0x0AC00, 0x0F900, 0x0FE10, 0x0FE30, 0x0FE54, + 0x0FE68, 0x0FF01, 0x0FFE0, 0x16FE0, 0x16FF0, 0x17000, 0x18800, 0x18CFF, + 0x1AFF0, 0x1AFF5, 0x1AFFD, 0x1B000, 0x1B132, 0x1B150, 0x1B155, 0x1B164, + 0x1B170, 0x1D300, 0x1D360, 0x1F004, 0x1F0CF, 0x1F18E, 0x1F191, 0x1F200, + 0x1F210, 0x1F240, 0x1F250, 0x1F260, 0x1F300, 0x1F32D, 0x1F337, 0x1F37E, + 0x1F3A0, 0x1F3CF, 0x1F3E0, 0x1F3F4, 0x1F3F8, 0x1F440, 0x1F442, 0x1F4FF, + 0x1F54B, 0x1F550, 0x1F57A, 0x1F595, 0x1F5A4, 0x1F5FB, 0x1F680, 0x1F6CC, + 0x1F6D0, 0x1F6D5, 0x1F6DC, 0x1F6EB, 0x1F6F4, 0x1F7E0, 0x1F7F0, 0x1F90C, + 0x1F93C, 0x1F947, 0x1FA70, 0x1FA80, 0x1FA8F, 0x1FACE, 0x1FADF, 0x1FAF0, + 0x20000, 0x30000, + ]; + + private static readonly int[] WideRangeEnds = + [ + 0x0115F, 0x0231B, 0x0232A, 0x023EC, 0x023F0, 0x023F3, 0x025FE, 0x02615, + 0x02637, 0x02653, 0x0267F, 0x0268F, 0x02693, 0x026A1, 0x026AB, 0x026BE, + 0x026C5, 0x026CE, 0x026D4, 0x026EA, 0x026F3, 0x026F5, 0x026FA, 0x026FD, + 0x02705, 0x0270B, 0x02728, 0x0274C, 0x0274E, 0x02755, 0x02757, 0x02797, + 0x027B0, 0x027BF, 0x02B1C, 0x02B50, 0x02B55, 0x02E99, 0x02EF3, 0x02FD5, + 0x0303E, 0x03096, 0x030FF, 0x0312F, 0x0318E, 0x031E5, 0x0321E, 0x03247, + 0x0A48C, 0x0A4C6, 0x0A97C, 0x0D7A3, 0x0FAFF, 0x0FE19, 0x0FE52, 0x0FE66, + 0x0FE6B, 0x0FF60, 0x0FFE6, 0x16FE4, 0x16FF1, 0x187F7, 0x18CD5, 0x18D08, + 0x1AFF3, 0x1AFFB, 0x1AFFE, 0x1B122, 0x1B132, 0x1B152, 0x1B155, 0x1B167, + 0x1B2FB, 0x1D356, 0x1D376, 0x1F004, 0x1F0CF, 0x1F18E, 0x1F19A, 0x1F202, + 0x1F23B, 0x1F248, 0x1F251, 0x1F265, 0x1F320, 0x1F335, 0x1F37C, 0x1F393, + 0x1F3CA, 0x1F3D3, 0x1F3F0, 0x1F3F4, 0x1F43E, 0x1F440, 0x1F4FC, 0x1F53D, + 0x1F54E, 0x1F567, 0x1F57A, 0x1F596, 0x1F5A4, 0x1F64F, 0x1F6C5, 0x1F6CC, + 0x1F6D2, 0x1F6D7, 0x1F6DF, 0x1F6EC, 0x1F6FC, 0x1F7EB, 0x1F7F0, 0x1F93A, + 0x1F945, 0x1F9FF, 0x1FA7C, 0x1FA89, 0x1FAC6, 0x1FADC, 0x1FAE9, 0x1FAF8, + 0x2FFFD, 0x3FFFD, + ]; } diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs index 0755941653..a8d95e2217 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs @@ -170,7 +170,7 @@ public void ParseAvailableUpdatesReadsHeadersWhoseColumnNameContainsASpace() manager, Lines( """ - 이름 ID 버전 사용 가능 소스 + 이름 장치 ID 버전 사용 가능 원본 ------------------------------------------------------------ 7-Zip 24.09 (x64) 7zip.7zip 24.09 25.00 winget 메모장 Notepad++.Notepad++ 8.9.7 8.9.8 winget @@ -534,7 +534,7 @@ public void ParseAvailableUpdatesHandlesAMultiwordHeaderWithoutASourceColumn() manager, Lines( """ - 이름 ID 버전 사용 가능 + 이름 장치 ID 버전 사용 가능 --------------------------------------------- 7-Zip 24.09 (x64) 7zip.7zip 24.09 2026.2.16.0 """ @@ -560,7 +560,7 @@ public void ParseInstalledPackagesHandlesAMultiwordHeaderWithoutASourceColumn() manager, Lines( """ - 이름 ID 버전 사용 가능 + 이름 장치 ID 버전 사용 가능 --------------------------------------------- 7-Zip 24.09 (x64) 7zip.7zip 24.09 2026.2.16.0 """ @@ -571,6 +571,142 @@ 이름 ID 버전 사용 가능 Assert.Same(manager.LocalPcSource, packages[0].Source); } + [Fact] + public void ParseInstalledPackagesReadsRealKoreanHeaders() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + 이름 장치 ID 버전 사용 가능 원본 + ------------------------------------------------------------ + 7-Zip 24.09 (x64) 7zip.7zip 24.09 25.00 winget + 메모장 Notepad++.Notepad++ 8.9.7 8.9.8 winget + """ + ) + ); + + Assert.Equal(2, packages.Count); + PackageAssert.Matches(packages[0], "7-Zip 24.09 (x64)", "7zip.7zip", "24.09"); + PackageAssert.Matches(packages[1], "메모장", "Notepad++.Notepad++", "8.9.7"); + Assert.Equal("winget", packages[1].Source.Name); + } + + [Fact] + public void ParseInstalledPackagesReadsAnAvailableColumnWithoutASourceColumn() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + Name Id Version Available + ---------------------------------------------------- + 7-Zip 24.09 (x64) 7zip.7zip 24.09 25.00 + Contoso Tool Programs\Contoso 1.0.0 + """ + ) + ); + + Assert.Equal(2, packages.Count); + PackageAssert.Matches(packages[0], "7-Zip 24.09 (x64)", "7zip.7zip", "24.09"); + PackageAssert.Matches(packages[1], "Contoso Tool", @"Programs\Contoso", "1.0.0"); + Assert.Same(manager.LocalPcSource, packages[0].Source); + Assert.Same(manager.LocalPcSource, packages[1].Source); + } + + [Fact] + public void ParseInstalledPackagesTreatsAmbiguousWidthCharactersAsNarrow() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + Name Id Version + --------------------------------- + ㉈㉈㉈ Widget Contoso.Widget 1.0.0 + """ + ) + ); + + PackageAssert.Matches( + Assert.Single(packages), + "㉈㉈㉈ Widget", + "Contoso.Widget", + "1.0.0" + ); + } + + [Fact] + public void ParseInstalledPackagesTreatsRepeatedWideEmojiAsTwoColumnsEach() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + Name Id Version + -------------------------------- + 🎮🎮🎮 Game Contoso.Game 2.0.0 + """ + ) + ); + + PackageAssert.Matches(Assert.Single(packages), "🎮🎮🎮 Game", "Contoso.Game", "2.0.0"); + } + + [Fact] + public void ParseAvailableUpdatesMergesKoreanIdHeaderForASingleRowTable() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseAvailableUpdates( + manager, + Lines( + """ + 이름 장치 ID 버전 사용 가능 원본 + -------------------------------------------------- + 7-Zip 24.09 (x64) 7zip.7zip 24.09 25.00 winget + 사용 가능한 업그레이드 1개 + """ + ) + ); + + PackageAssert.Matches( + Assert.Single(packages), + "7-Zip 24.09 (x64)", + "7zip.7zip", + "24.09", + "25.00" + ); + } + + [Fact] + public void ParseInstalledPackagesIgnoresProseAlignedToTheIdColumn() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + Name Id Version Source + ------------------------------------------ + 7-Zip 24.09 (x64) 7zip.7zip 24.09 winget + A pinned package: use the 'winget pin' command to view and edit pins + """ + ) + ); + + PackageAssert.Matches(Assert.Single(packages), "7-Zip 24.09 (x64)", "7zip.7zip", "24.09"); + } + [Fact] public void ParseInstalledPackagesIgnoresOutputWithoutATable() { From 4fa011df57255b2f0e189070d379cb5210212cc4 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 8 Sep 2026 13:03:09 -0400 Subject: [PATCH 4/6] Merge a header continuation whenever no row starts a cell on it --- .../ClientHelpers/WinGetTableLayout.cs | 14 ++++------- .../WinGetCliParsingTests.cs | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs index 3a1a300c13..2f3a4d4556 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs @@ -170,22 +170,18 @@ public WinGetTableLayout MergeContinuationColumns(IReadOnlyList rows) for (int column = 1; column < _columnStarts.Length; column++) { - int straddled = 0; - int startsACell = 0; + bool startsACell = false; foreach (string row in rows) { - if (Straddles(row, _columnStarts[column])) + if (StartsACell(row, _columnStarts[column])) { - straddled++; - } - else if (StartsACell(row, _columnStarts[column])) - { - startsACell++; + startsACell = true; + break; } } - if (straddled > startsACell && remaining - 1 >= MinimumColumns) + if (!startsACell && remaining - 1 >= MinimumColumns) { remaining--; } diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs index a8d95e2217..c80bc582e7 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs @@ -707,6 +707,29 @@ Name Id Version Source PackageAssert.Matches(Assert.Single(packages), "7-Zip 24.09 (x64)", "7zip.7zip", "24.09"); } + [Fact] + public void ParseInstalledPackagesMergesKoreanIdHeaderWhenEveryIdIsShort() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + 이름 장치 ID 버전 + ------------------------ + Vim Editor Vim 9.1.0 + cURL cURL 8.5.0 + """ + ) + ); + + Assert.Equal(2, packages.Count); + PackageAssert.Matches(packages[0], "Vim Editor", "Vim", "9.1.0"); + PackageAssert.Matches(packages[1], "cURL", "cURL", "8.5.0"); + Assert.Same(manager.LocalPcSource, packages[0].Source); + } + [Fact] public void ParseInstalledPackagesIgnoresOutputWithoutATable() { From 620cee297f5a4af1c41933f24be1a7078d4eff41 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 8 Sep 2026 13:25:07 -0400 Subject: [PATCH 5/6] Weigh a continuation against every row, not against the first cell start --- .../ClientHelpers/WinGetTableLayout.cs | 14 +++++---- .../WinGetCliParsingTests.cs | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs index 2f3a4d4556..6418000666 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs @@ -170,18 +170,22 @@ public WinGetTableLayout MergeContinuationColumns(IReadOnlyList rows) for (int column = 1; column < _columnStarts.Length; column++) { - bool startsACell = false; + int straddled = 0; + int startsACell = 0; foreach (string row in rows) { - if (StartsACell(row, _columnStarts[column])) + if (Straddles(row, _columnStarts[column])) { - startsACell = true; - break; + straddled++; + } + else if (StartsACell(row, _columnStarts[column])) + { + startsACell++; } } - if (!startsACell && remaining - 1 >= MinimumColumns) + if ((startsACell == 0 || straddled > startsACell) && remaining - 1 >= MinimumColumns) { remaining--; } diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs index c80bc582e7..b2c3c30be5 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs @@ -730,6 +730,35 @@ cURL cURL 8.5.0 Assert.Same(manager.LocalPcSource, packages[0].Source); } + [Fact] + public void ParseInstalledPackagesMergesKoreanIdHeaderWhenAnIdAlignsWithTheContinuation() + { + var manager = new WinGet(); + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Lines( + """ + 이름 장치 ID 버전 + ----------------------------------------------------- + Dell SupportAssist Dell App 3.14.1 + Visual Studio Code Microsoft.VisualStudioCode 1.136.1 + Git Git.Git 2.51.0 + """ + ) + ); + + Assert.Equal(3, packages.Count); + PackageAssert.Matches(packages[0], "Dell SupportAssist", "Dell App", "3.14.1"); + PackageAssert.Matches( + packages[1], + "Visual Studio Code", + "Microsoft.VisualStudioCode", + "1.136.1" + ); + PackageAssert.Matches(packages[2], "Git", "Git.Git", "2.51.0"); + } + [Fact] public void ParseInstalledPackagesIgnoresOutputWithoutATable() { From b7a4d790349612b888ab0d1be303c94df4bc7137 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 8 Sep 2026 14:13:28 -0400 Subject: [PATCH 6/6] Identify localized column headers instead of inferring their boundaries --- .../ClientHelpers/WinGetTableLayout.cs | 152 +++++++-- .../WinGetLocalizedTableMatrixTests.cs | 314 ++++++++++++++++++ 2 files changed, 436 insertions(+), 30 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Tests/WinGetLocalizedTableMatrixTests.cs diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs index 6418000666..474bf3f2d0 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs @@ -3,6 +3,16 @@ namespace UniGetUI.PackageEngine.Managers.WingetManager; +internal enum HeaderKind +{ + Name, + Id, + Version, + Available, + Match, + Source, +} + internal sealed record WinGetTable(WinGetTableLayout Layout, IReadOnlyList Rows); internal sealed class WinGetTableLayout @@ -14,32 +24,72 @@ internal sealed class WinGetTableLayout private const int MinimumColumns = 3; - private static readonly HashSet AvailableOrMatchHeaders = new( + private static readonly Dictionary HeaderNames = new( StringComparer.OrdinalIgnoreCase ) { - "Available", - "AvailableHeader", - "Coincidencia", - "Correspondance", - "Correspondência", - "Corrispondenza", - "Disponibile", - "Disponible", - "Disponível", - "Match", - "SearchMatch", - "Verfügbar", - "Übereinstimmung", - "Доступно", - "Совпадение", - "一致", - "利用可能", - "匹配", - "可用", - "相符", - "사용 가능", - "일치", + { "Name", HeaderKind.Name }, + { "Nom", HeaderKind.Name }, + { "Nombre", HeaderKind.Name }, + { "Nome", HeaderKind.Name }, + { "SearchName", HeaderKind.Name }, + { "Имя", HeaderKind.Name }, + { "名前", HeaderKind.Name }, + { "名称", HeaderKind.Name }, + { "名稱", HeaderKind.Name }, + { "이름", HeaderKind.Name }, + + { "ID", HeaderKind.Id }, + { "SearchId", HeaderKind.Id }, + { "ИД", HeaderKind.Id }, + { "識別碼", HeaderKind.Id }, + { "장치 ID", HeaderKind.Id }, + + { "SearchVersion", HeaderKind.Version }, + { "Version", HeaderKind.Version }, + { "Versione", HeaderKind.Version }, + { "Versión", HeaderKind.Version }, + { "Versão", HeaderKind.Version }, + { "Версия", HeaderKind.Version }, + { "バージョン", HeaderKind.Version }, + { "版本", HeaderKind.Version }, + { "버전", HeaderKind.Version }, + + { "Available", HeaderKind.Available }, + { "AvailableHeader", HeaderKind.Available }, + { "Disponibile", HeaderKind.Available }, + { "Disponible", HeaderKind.Available }, + { "Disponível", HeaderKind.Available }, + { "Verfügbar", HeaderKind.Available }, + { "Доступно", HeaderKind.Available }, + { "利用可能", HeaderKind.Available }, + { "可用", HeaderKind.Available }, + { "사용 가능", HeaderKind.Available }, + + { "Coincidencia", HeaderKind.Match }, + { "Correspondance", HeaderKind.Match }, + { "Correspondência", HeaderKind.Match }, + { "Corrispondenza", HeaderKind.Match }, + { "Match", HeaderKind.Match }, + { "SearchMatch", HeaderKind.Match }, + { "Übereinstimmung", HeaderKind.Match }, + { "Совпадение", HeaderKind.Match }, + { "一致", HeaderKind.Match }, + { "匹配", HeaderKind.Match }, + { "相符", HeaderKind.Match }, + { "일치", HeaderKind.Match }, + + { "Origem", HeaderKind.Source }, + { "Origen", HeaderKind.Source }, + { "Origine", HeaderKind.Source }, + { "Quelle", HeaderKind.Source }, + { "SearchSource", HeaderKind.Source }, + { "Source", HeaderKind.Source }, + { "Источник", HeaderKind.Source }, + { "ソース", HeaderKind.Source }, + { "來源", HeaderKind.Source }, + { "源", HeaderKind.Source }, + { "원본", HeaderKind.Source }, }; private readonly string _headerLine; @@ -58,11 +108,7 @@ private WinGetTableLayout(string headerLine, int[] columnStarts, int tableWidth) public int LastColumn => _columnStarts.Length - 1; public bool HasSourceColumn => - ColumnCount >= 5 - || ( - ColumnCount == 4 - && !AvailableOrMatchHeaders.Contains(GetCell(_headerLine, LastColumn)) - ); + ColumnCount >= 5 || (ColumnCount == 4 && !LastHeaderIsAvailableOrMatch()); public static bool IsSeparatorLine(string line) { @@ -160,7 +206,7 @@ private static WinGetTable BuildTable(WinGetTableLayout layout, List row public WinGetTableLayout MergeContinuationColumns(IReadOnlyList rows) { - if (rows.Count == 0 || _columnStarts.Length <= MinimumColumns) + if (_columnStarts.Length <= MinimumColumns) { return this; } @@ -170,6 +216,12 @@ public WinGetTableLayout MergeContinuationColumns(IReadOnlyList rows) for (int column = 1; column < _columnStarts.Length; column++) { + if (remaining - 1 >= MinimumColumns && CompletesAHeaderName(kept[^1], column)) + { + remaining--; + continue; + } + int straddled = 0; int startsACell = 0; @@ -185,7 +237,11 @@ public WinGetTableLayout MergeContinuationColumns(IReadOnlyList rows) } } - if ((startsACell == 0 || straddled > startsACell) && remaining - 1 >= MinimumColumns) + if ( + rows.Count > 0 + && (startsACell == 0 || straddled > startsACell) + && remaining - 1 >= MinimumColumns + ) { remaining--; } @@ -200,6 +256,42 @@ public WinGetTableLayout MergeContinuationColumns(IReadOnlyList rows) : new WinGetTableLayout(_headerLine, [.. kept], _tableWidth); } + private bool CompletesAHeaderName(int previousStart, int column) + { + int endColumn = + column + 1 < _columnStarts.Length ? _columnStarts[column + 1] : int.MaxValue; + + return HeaderNames.ContainsKey(HeaderTextBetween(previousStart, endColumn)); + } + + private bool LastHeaderIsAvailableOrMatch() + { + string text = GetCell(_headerLine, LastColumn); + return HeaderNames.TryGetValue(text, out HeaderKind kind) + && kind is HeaderKind.Available or HeaderKind.Match; + } + + private string HeaderTextBetween(int startDisplayColumn, int endDisplayColumn) + { + int start = CharIndexOfColumn(_headerLine, startDisplayColumn); + if (start >= _headerLine.Length) + { + return ""; + } + + int end = + endDisplayColumn == int.MaxValue + ? _headerLine.Length + : CharIndexOfColumn(_headerLine, endDisplayColumn); + + if (end > _headerLine.Length) + { + end = _headerLine.Length; + } + + return end <= start ? "" : _headerLine[start..end].Trim(); + } + public bool IsRowReaching(string line, int column) { if (column < 0 || column >= _columnStarts.Length) diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetLocalizedTableMatrixTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetLocalizedTableMatrixTests.cs new file mode 100644 index 0000000000..604b353b3c --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/WinGetLocalizedTableMatrixTests.cs @@ -0,0 +1,314 @@ +#if WINDOWS +using System.Text; +using UniGetUI.Core.Data; +using UniGetUI.PackageEngine.Managers.WingetManager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(WinGetManagerTestCollection.Name)] +public sealed class WinGetLocalizedTableMatrixTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + AppContext.BaseDirectory, + "WinGetLocalizedTableMatrixTests", + Guid.NewGuid().ToString("N") + ); + + public WinGetLocalizedTableMatrixTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + } + + public void Dispose() + { + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + private sealed record Locale( + string Tag, + string Name, + string Id, + string Version, + string Available, + string Match, + string Source + ); + + private static readonly Locale[] Locales = + [ + new("en-US", "Name", "Id", "Version", "Available", "Match", "Source"), + new("resw", "SearchName", "SearchId", "SearchVersion", "AvailableHeader", "SearchMatch", "SearchSource"), + new("de-DE", "Name", "ID", "Version", "Verfügbar", "Übereinstimmung", "Quelle"), + new("es-ES", "Nombre", "Id", "Versión", "Disponible", "Coincidencia", "Origen"), + new("fr-FR", "Nom", "ID", "Version", "Disponible", "Correspondance", "Source"), + new("it-IT", "Nome", "Id", "Versione", "Disponibile", "Corrispondenza", "Origine"), + new("ja-JP", "名前", "ID", "バージョン", "利用可能", "一致", "ソース"), + new("ko-KR", "이름", "장치 ID", "버전", "사용 가능", "일치", "원본"), + new("pt-BR", "Nome", "ID", "Versão", "Disponível", "Correspondência", "Origem"), + new("ru-RU", "Имя", "ИД", "Версия", "Доступно", "Совпадение", "Источник"), + new("zh-CN", "名称", "ID", "版本", "可用", "匹配", "源"), + new("zh-TW", "名稱", "識別碼", "版本", "可用", "相符", "來源"), + ]; + + private static int Width(string text) + { + int width = 0; + foreach (char c in text) + { + width += + c is >= 'ᄀ' and <= 'ᅟ' + or >= '⺀' and <= '〾' + or >= 'ぁ' and <= '㏿' + or >= '㐀' and <= '䶿' + or >= '一' and <= '鿿' + or >= '가' and <= '힣' + or >= '豈' and <= '﫿' + or >= '!' and <= '⦆' + ? 2 + : 1; + } + + return width; + } + + private static string[] Render(IReadOnlyList rows) + { + int columns = rows[0].Length; + int[] widths = new int[columns]; + foreach (string[] row in rows) + { + for (int i = 0; i < columns; i++) + { + widths[i] = Math.Max(widths[i], Width(row[i])); + } + } + + List lines = []; + foreach (string[] row in rows) + { + StringBuilder line = new(); + for (int i = 0; i < columns; i++) + { + if (i == columns - 1) + { + line.Append(row[i]); + break; + } + + bool restEmpty = true; + for (int j = i + 1; j < columns; j++) + { + restEmpty &= row[j].Length == 0; + } + + if (row[i].Length == 0 && restEmpty) + { + line.Append(' ', widths[i] - Width(row[i]) + 1); + continue; + } + + line.Append(row[i]).Append(' ', widths[i] - Width(row[i]) + 1); + } + + lines.Add(line.ToString().TrimEnd()); + if (lines.Count == 1) + { + lines.Add(new string('-', widths.Sum() + columns - 1)); + } + } + + return [.. lines]; + } + + public static TheoryData ListShapes() + { + TheoryData data = []; + foreach (Locale locale in Locales) + { + foreach (bool available in new[] { false, true }) + { + foreach (bool source in new[] { false, true }) + { + foreach (bool singleRow in new[] { false, true }) + { + data.Add(locale.Tag, available, source, singleRow); + } + } + } + } + + return data; + } + + [Theory] + [MemberData(nameof(ListShapes))] + public void InstalledPackagesParseInEveryLocaleAndColumnShape( + string tag, + bool withAvailable, + bool withSource, + bool singleRow + ) + { + Locale locale = Locales.First(l => l.Tag == tag); + var manager = new WinGet(); + + List header = [locale.Name, locale.Id, locale.Version]; + if (withAvailable) + header.Add(locale.Available); + if (withSource) + header.Add(locale.Source); + + string[] Row(string name, string id, string version, string available, string source) + { + List cells = [name, id, version]; + if (withAvailable) + cells.Add(available); + if (withSource) + cells.Add(source); + return [.. cells]; + } + + List rows = [[.. header]]; + + // A spaced identifier whose second word lands on Korean's "장치 ID" continuation. + rows.Add(Row("Dell SupportAssist", "Dell App", "3.14.1", "3.15.0", "winget")); + if (!singleRow) + { + rows.Add(Row("Visual Studio Code", "Microsoft.VisualStudioCode", "1.136.1", "", "winget")); + rows.Add(Row("Vim", "Vim", "9.1", "", "")); + } + + IReadOnlyList packages = WinGetCliHelper.ParseInstalledPackages( + manager, + Render(rows) + ); + + Assert.Equal(singleRow ? 1 : 3, packages.Count); + PackageAssert.Matches(packages[0], "Dell SupportAssist", "Dell App", "3.14.1"); + if (withSource) + { + Assert.Equal("winget", packages[0].Source.Name); + } + + if (!singleRow) + { + PackageAssert.Matches( + packages[1], + "Visual Studio Code", + "Microsoft.VisualStudioCode", + "1.136.1" + ); + PackageAssert.Matches(packages[2], "Vim", "Vim", "9.1"); + Assert.Same(manager.LocalPcSource, packages[2].Source); + } + } + + [Theory] + [MemberData(nameof(ListShapes))] + public void FoundPackagesParseInEveryLocaleAndColumnShape( + string tag, + bool withMatch, + bool withSource, + bool singleRow + ) + { + Locale locale = Locales.First(l => l.Tag == tag); + var manager = new WinGet(); + + List header = [locale.Name, locale.Id, locale.Version]; + if (withMatch) + header.Add(locale.Match); + if (withSource) + header.Add(locale.Source); + + string[] Row(string name, string id, string version, string match, string source) + { + List cells = [name, id, version]; + if (withMatch) + cells.Add(match); + if (withSource) + cells.Add(source); + return [.. cells]; + } + + List rows = [[.. header]]; + rows.Add(Row("Dell SupportAssist", "Dell App", "3.14.1", "Moniker: dell", "winget")); + if (!singleRow) + { + rows.Add(Row("VLC", "XPDM1ZW6815MQM", "Unknown", "", "msstore")); + } + + IReadOnlyList packages = WinGetCliHelper.ParseFoundPackages( + manager, + Render(rows) + ); + + Assert.Equal(singleRow ? 1 : 2, packages.Count); + PackageAssert.Matches(packages[0], "Dell SupportAssist", "Dell App", "3.14.1"); + if (withSource) + { + Assert.Equal("winget", packages[0].Source.Name); + } + + if (!singleRow) + { + PackageAssert.Matches(packages[1], "VLC", "XPDM1ZW6815MQM", "Unknown"); + if (withSource) + { + Assert.Equal("msstore", packages[1].Source.Name); + } + } + } + + [Theory] + [MemberData(nameof(ListShapes))] + public void AvailableUpdatesParseInEveryLocaleAndColumnShape( + string tag, + bool _, + bool withSource, + bool singleRow + ) + { + Locale locale = Locales.First(l => l.Tag == tag); + var manager = new WinGet(); + + List header = [locale.Name, locale.Id, locale.Version, locale.Available]; + if (withSource) + header.Add(locale.Source); + + string[] Row(string name, string id, string version, string available, string source) + { + List cells = [name, id, version, available]; + if (withSource) + cells.Add(source); + return [.. cells]; + } + + List rows = [[.. header]]; + rows.Add(Row("Dell SupportAssist", "Dell App", "3.14.1", "3.15.0", "winget")); + if (!singleRow) + { + rows.Add(Row("Git", "Git.Git", "2.51.0", "2.52.0", "winget")); + } + + IReadOnlyList packages = WinGetCliHelper.ParseAvailableUpdates( + manager, + Render(rows) + ); + + Assert.Equal(singleRow ? 1 : 2, packages.Count); + PackageAssert.Matches(packages[0], "Dell SupportAssist", "Dell App", "3.14.1", "3.15.0"); + if (!singleRow) + { + PackageAssert.Matches(packages[1], "Git", "Git.Git", "2.51.0", "2.52.0"); + } + } +} +#endif