Skip to content

Commit 1b02361

Browse files
GabrielDufclaude
andcommitted
Measure grapheme clusters and resolve the optional Source column per row
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) <noreply@anthropic.com>
1 parent d3518e6 commit 1b02361

3 files changed

Lines changed: 106 additions & 38 deletions

File tree

src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,10 @@ layout is not null
131131

132132
string newVersion;
133133
IManagerSource source;
134-
if (layout.ColumnCount >= 5)
134+
if (
135+
layout.ColumnCount >= 5
136+
&& layout.StartsSeparateCell(line, layout.LastColumn)
137+
)
135138
{
136139
newVersion = layout.GetCell(
137140
line,
@@ -260,7 +263,10 @@ layout is not null
260263
string version = layout.GetCell(line, WinGetTableLayout.VersionColumn);
261264

262265
string sourceName =
263-
layout.ColumnCount >= 4 ? layout.GetCell(line, layout.LastColumn) : "";
266+
layout.ColumnCount >= 4
267+
&& layout.StartsSeparateCell(line, layout.LastColumn)
268+
? layout.GetCell(line, layout.LastColumn)
269+
: "";
264270

265271
IManagerSource source =
266272
sourceName.Length == 0
@@ -359,7 +365,10 @@ layout is not null
359365
string version = layout.GetCell(line, WinGetTableLayout.VersionColumn);
360366

361367
string sourceName =
362-
layout.ColumnCount >= 4 ? layout.GetCell(line, layout.LastColumn) : "";
368+
layout.ColumnCount >= 4
369+
&& layout.StartsSeparateCell(line, layout.LastColumn)
370+
? layout.GetCell(line, layout.LastColumn)
371+
: "";
363372

364373
IManagerSource source =
365374
sourceName.Length == 0

src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetTableLayout.cs

Lines changed: 24 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,7 @@ public static bool IsSeparatorLine(string line)
4949

5050
while (index < headerLine.Length)
5151
{
52-
int length = CodePointLength(headerLine, index);
53-
int codePoint =
54-
length == 2
55-
? char.ConvertToUtf32(headerLine[index], headerLine[index + 1])
56-
: headerLine[index];
52+
int codePoint = FirstCodePoint(headerLine, index);
5753
bool isSpace = codePoint == ' ';
5854

5955
if (!isSpace && previousWasSpace)
@@ -63,7 +59,7 @@ public static bool IsSeparatorLine(string line)
6359

6460
previousWasSpace = isSpace;
6561
displayColumn += GetDisplayWidth(codePoint);
66-
index += length;
62+
index += TextElementLength(headerLine, index);
6763
}
6864

6965
return columnStarts.Count >= 3
@@ -86,6 +82,17 @@ public bool IsRowReaching(string line, int column)
8682
return CharIndexOfColumn(line, _columnStarts[column]) < line.Length;
8783
}
8884

85+
public bool StartsSeparateCell(string line, int column)
86+
{
87+
if (column <= 0 || column >= _columnStarts.Length)
88+
{
89+
return false;
90+
}
91+
92+
return CharIndexOfColumn(line, _columnStarts[column])
93+
> CharIndexOfColumn(line, _columnStarts[column - 1]);
94+
}
95+
8996
public string GetCell(string line, int column) => GetCell(line, column, column + 1);
9097

9198
public string GetCell(string line, int firstColumn, int columnAfterLast)
@@ -121,11 +128,8 @@ private static int DisplayWidth(string line)
121128

122129
while (index < line.Length)
123130
{
124-
int length = CodePointLength(line, index);
125-
int codePoint =
126-
length == 2 ? char.ConvertToUtf32(line[index], line[index + 1]) : line[index];
127-
width += GetDisplayWidth(codePoint);
128-
index += length;
131+
width += GetDisplayWidth(FirstCodePoint(line, index));
132+
index += TextElementLength(line, index);
129133
}
130134

131135
return width;
@@ -138,11 +142,8 @@ private static int CharIndexOfColumn(string line, int displayColumn)
138142

139143
while (index < line.Length && width < displayColumn)
140144
{
141-
int length = CodePointLength(line, index);
142-
int codePoint =
143-
length == 2 ? char.ConvertToUtf32(line[index], line[index + 1]) : line[index];
144-
width += GetDisplayWidth(codePoint);
145-
index += length;
145+
width += GetDisplayWidth(FirstCodePoint(line, index));
146+
index += TextElementLength(line, index);
146147
}
147148

148149
while (index > 0 && index < line.Length && line[index] != ' ' && line[index - 1] != ' ')
@@ -153,29 +154,17 @@ private static int CharIndexOfColumn(string line, int displayColumn)
153154
return index;
154155
}
155156

156-
private static int CodePointLength(string text, int index) =>
157+
private static int TextElementLength(string text, int index) =>
158+
Math.Max(1, StringInfo.GetNextTextElementLength(text.AsSpan(index)));
159+
160+
private static int FirstCodePoint(string text, int index) =>
157161
char.IsHighSurrogate(text[index])
158162
&& index + 1 < text.Length
159163
&& char.IsLowSurrogate(text[index + 1])
160-
? 2
161-
: 1;
162-
163-
private static int GetDisplayWidth(int codePoint)
164-
{
165-
UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory(codePoint);
166-
if (
167-
category
168-
is UnicodeCategory.NonSpacingMark
169-
or UnicodeCategory.EnclosingMark
170-
or UnicodeCategory.Format
171-
or UnicodeCategory.Control
172-
)
173-
{
174-
return 0;
175-
}
164+
? char.ConvertToUtf32(text[index], text[index + 1])
165+
: text[index];
176166

177-
return IsFullWidth(codePoint) ? 2 : 1;
178-
}
167+
private static int GetDisplayWidth(int codePoint) => IsFullWidth(codePoint) ? 2 : 1;
179168

180169
private static bool IsFullWidth(int codePoint) =>
181170
codePoint

src/UniGetUI.PackageEngine.Tests/WinGetCliParsingTests.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,76 @@ Name Id Version
501501
PackageAssert.Matches(Assert.Single(packages), "🎮 Game", "Contoso.Game", "1.0.0");
502502
}
503503

504+
[Fact]
505+
public void ParseInstalledPackagesMeasuresGraphemeClustersAsOneCell()
506+
{
507+
var manager = new WinGet();
508+
509+
IReadOnlyList<Package> packages = WinGetCliHelper.ParseInstalledPackages(
510+
manager,
511+
Lines(
512+
"""
513+
Name Id Version
514+
--------------------------------
515+
👨‍👩‍👧‍👦 Family Contoso.Family 1.0.0
516+
"""
517+
)
518+
);
519+
520+
PackageAssert.Matches(
521+
Assert.Single(packages),
522+
"👨‍👩‍👧‍👦 Family",
523+
"Contoso.Family",
524+
"1.0.0"
525+
);
526+
}
527+
528+
[Fact]
529+
public void ParseAvailableUpdatesHandlesAMultiwordHeaderWithoutASourceColumn()
530+
{
531+
var manager = new WinGet();
532+
533+
IReadOnlyList<Package> packages = WinGetCliHelper.ParseAvailableUpdates(
534+
manager,
535+
Lines(
536+
"""
537+
이름 ID 버전 사용 가능
538+
---------------------------------------------
539+
7-Zip 24.09 (x64) 7zip.7zip 24.09 2026.2.16.0
540+
"""
541+
)
542+
);
543+
544+
PackageAssert.Matches(
545+
Assert.Single(packages),
546+
"7-Zip 24.09 (x64)",
547+
"7zip.7zip",
548+
"24.09",
549+
"2026.2.16.0"
550+
);
551+
Assert.Same(manager.DefaultSource, packages[0].Source);
552+
}
553+
554+
[Fact]
555+
public void ParseInstalledPackagesHandlesAMultiwordHeaderWithoutASourceColumn()
556+
{
557+
var manager = new WinGet();
558+
559+
IReadOnlyList<Package> packages = WinGetCliHelper.ParseInstalledPackages(
560+
manager,
561+
Lines(
562+
"""
563+
이름 ID 버전 사용 가능
564+
---------------------------------------------
565+
7-Zip 24.09 (x64) 7zip.7zip 24.09 2026.2.16.0
566+
"""
567+
)
568+
);
569+
570+
PackageAssert.Matches(Assert.Single(packages), "7-Zip 24.09 (x64)", "7zip.7zip", "24.09");
571+
Assert.Same(manager.LocalPcSource, packages[0].Source);
572+
}
573+
504574
[Fact]
505575
public void ParseInstalledPackagesIgnoresOutputWithoutATable()
506576
{

0 commit comments

Comments
 (0)