55using System . Linq ;
66using System . Runtime . InteropServices ;
77using System . Text ;
8+ using System . Text . RegularExpressions ;
89
910using UiaPeek . Domain . Models ;
1011
@@ -135,9 +136,7 @@ public static UiaChainModel NewAncestorChain(this CUIAutomation8 automation, IUI
135136 /// <returns>A deterministic locator string beginning with <c>/Desktop</c>.</returns>
136137 public static string ResolveLocator ( this UiaChainModel chain )
137138 {
138- // Identifiers containing quotes cannot be safely embedded in XPath attribute predicates.
139- static bool IsBroken ( string input ) => input . Contains ( '\' ' ) || input . Contains ( '"' ) ;
140-
139+ // Extract the ancestor nodes from the chain, defaulting to an empty list if the chain is null.
141140 var nodes = chain ? . Path ?? [ ] ;
142141 var builder = new StringBuilder ( "/Desktop" ) ;
143142
@@ -163,9 +162,12 @@ public static string ResolveLocator(this UiaChainModel chain)
163162 var separator = isGap ? "//" : "/" ;
164163 isGap = false ;
165164
165+ // Attempt to use AutomationId as the strongest available identifier, falling back to Name when safe,
166+ // otherwise using a positional index among siblings of the same ControlType.
166167 var automationId = node . AutomationId ;
167168 var name = node . Name ;
168169
170+ // Identifiers that contain quotes cannot be used in XPath predicates and are considered broken.
169171 var hasAutomationId = ! string . IsNullOrEmpty ( automationId ) && ! IsBroken ( automationId ) ;
170172 var hasName = ! string . IsNullOrEmpty ( name ) && ! IsBroken ( name ) ;
171173
@@ -186,9 +188,98 @@ public static string ResolveLocator(this UiaChainModel chain)
186188 }
187189 }
188190
191+ // Return the constructed locator string.
189192 return builder . ToString ( ) ;
193+
194+ // Identifiers containing quotes cannot be safely embedded in XPath attribute predicates.
195+ static bool IsBroken ( string input ) => input . Contains ( '\' ' ) || input . Contains ( '"' ) ;
196+ }
197+
198+ // TODO: Add support for other stable attributes such as ClassName when they can be safely used in XPath predicates.
199+ /// <summary>
200+ /// Builds a compact semantic XPath from the fallback UIA XPath.
201+ /// </summary>
202+ /// <param name="chain">The UIA chain model that contains the fallback locator.</param>
203+ /// <returns>
204+ /// A normalized XPath that starts from <c>/Desktop</c> and targets the final
205+ /// element by a stable identity, or <c>null</c> when a safe normalized locator
206+ /// cannot be generated.
207+ /// </returns>
208+ public static string FormatXpath ( this UiaChainModel chain )
209+ {
210+ // Get the original full fallback XPath from the UIA chain.
211+ var xpath = chain ? . FallbackLocator ;
212+
213+ // A valid fallback XPath must be present and must start from the root.
214+ if ( string . IsNullOrWhiteSpace ( xpath ) || ! xpath . StartsWith ( '/' ) )
215+ {
216+ return null ;
217+ }
218+
219+ // Split the XPath into segments.
220+ // The regex captures each XPath separator and the segment that follows it.
221+ var matches = Regex . Matches ( xpath , @"(/+)([^/]+)" ) ;
222+
223+ // A normalized locator needs at least a root segment and a target segment.
224+ if ( matches . Count < 2 )
225+ {
226+ return null ;
227+ }
228+
229+ // Keep only the segment text.
230+ // Example: "/Desktop/Window[1]/Button[@Name='OK']"
231+ // becomes: "Desktop", "Window[1]", "Button[@Name='OK']".
232+ var segments = matches
233+ . Select ( m => m . Groups [ 2 ] . Value )
234+ . ToArray ( ) ;
235+
236+ // The final segment is the selected UIA element.
237+ var finalSegment = segments [ ^ 1 ] ;
238+
239+ // The final element must have a stable identity.
240+ // Index-only locators are intentionally rejected for normalized output.
241+ if ( ! finalSegment . Contains ( "[@AutomationId=" ) && ! finalSegment . Contains ( "[@Name=" ) )
242+ {
243+ return null ;
244+ }
245+
246+ // Do not generate a normalized element locator when the selected target
247+ // is itself a Window.
248+ if ( ControlType ( finalSegment ) . Equals ( "Window" , StringComparison . OrdinalIgnoreCase ) )
249+ {
250+ return null ;
251+ }
252+
253+ // Find the first Window ancestor before the final target segment.
254+ // The target itself is excluded from this search.
255+ var windowSegment = segments [ ..^ 1 ]
256+ . FirstOrDefault ( s => ControlType ( s ) . Equals ( "Window" , StringComparison . OrdinalIgnoreCase ) ) ;
257+
258+ // If no Window ancestor exists, search from Desktop directly to the target.
259+ if ( windowSegment == null )
260+ {
261+ return $ "/Desktop//{ finalSegment } ";
262+ }
263+
264+ // Anchor the locator through the Window ancestor and then search below it
265+ // for the final target element.
266+ var result = $ "/Desktop//{ windowSegment } //{ finalSegment } ";
267+
268+ // Return null when normalization produced the same value as the fallback.
269+ return result == xpath ? null : result ;
270+
271+ // Gets the control type name from an XPath segment.
272+ static string ControlType ( string segment )
273+ {
274+ // The control type is stored before the first '[' character.
275+ var idx = segment . IndexOf ( '[' ) ;
276+
277+ // If the segment has no predicate, the whole segment is the control type.
278+ return idx < 0 ? segment : segment [ ..idx ] ;
279+ }
190280 }
191281
282+ // TODO: Export all properties that can be safely retrieved from the element, such as IsContentElement, IsControlElement, IsEnabled, etc.
192283 // Converts an IUIAutomationElement into a UiaNodeModel representation.
193284 private static UiaNodeModel Convert ( IUIAutomationElement element , bool metadata )
194285 {
@@ -341,32 +432,44 @@ private static (int All, int SameControlType) GetSiblingIndexes(
341432
342433 try
343434 {
435+ // Start with the first child of the parent element.
436+ // If the parent has no children or retrieval fails, this will be null and the loop will be skipped.
344437 var child = Safe ( ( ) => walker . GetFirstChildElement ( parent ) , fallback : null ) ;
345438
439+ // Walk through siblings until the target element is found, counting positions.
346440 while ( child != null )
347441 {
348442 // Check whether this sibling is the target element.
349443 var isTarget = false ;
350444
445+ // CompareElements can throw if either element is stale or the provider
446+ // is buggy, so we catch exceptions and treat them as non-matches.
351447 try
352448 {
353449 isTarget = automation . CompareElements ( child , target ) == 1 ;
354450 }
355451 catch ( COMException ) { }
356452 catch ( InvalidComObjectException ) { }
357453
454+ // If this sibling is the target, stop counting; otherwise,
455+ // increment counts and move to the next sibling.
358456 if ( isTarget )
359457 {
360458 break ;
361459 }
362460
461+ // Increment the count of all siblings encountered so far.
462+ // This count is used to determine the target's position among all siblings.
363463 allCount ++ ;
364464
465+ // If this sibling shares the same ControlTypeId as the target, increment the same-type count.
365466 if ( Safe ( ( ) => child . CurrentControlType ) == targetControlTypeId )
366467 {
367468 sameTypeCount ++ ;
368469 }
369470
471+ // Move to the next sibling element, handling potential COM exceptions safely.
472+ // If retrieval fails, child will be set to null and the loop will exit.
370473 child = Safe ( ( ) => walker . GetNextSiblingElement ( child ) , fallback : null ) ;
371474 }
372475 }
0 commit comments