| 1 | #nullable enable |
| 2 | |
| 3 | using CascadeIDE.Features.Chat.AnchorPeek; |
| 4 | using CascadeIDE.Models.Intercom; |
| 5 | using CascadeIDE.Services.Intercom; |
| 6 | |
| 7 | namespace CascadeIDE.Features.Chat; |
| 8 | |
| 9 | /// <summary>Фасад форматирования и anchor peek для slash/CCL (ADR 0128 §10.1).</summary> |
| 10 | internal static class IntercomAnchorSlash |
| 11 | { |
| 12 | public static bool IsAnchorPeekPath(string canonicalPath) => |
| 13 | SlashPathAliases.IsAnchorPeekPath(canonicalPath); |
| 14 | |
| 15 | public static string? ExtractPeekIdTail(string canonicalPath, string? argTail) => |
| 16 | SlashPathAliases.ExtractPeekArgs(canonicalPath, argTail); |
| 17 | |
| 18 | public static bool IsAnchorPeekHexIdEntryBody(string body) => |
| 19 | isAnchorPeekHexIdEntryBody(SlashPathAliases.NormalizeCompletionBody(body)); |
| 20 | |
| 21 | internal static bool IsPartialHexAnchorId(string raw) => |
| 22 | AnchorPeekTargetParser.IsPartialHex(raw); |
| 23 | |
| 24 | public static bool TryResolvePeekOrdinal( |
| 25 | string? raw, |
| 26 | IReadOnlyList<AttachmentAnchor> selectedMessageAnchors, |
| 27 | out AttachmentAnchor anchor, |
| 28 | out int ordinal) => |
| 29 | tryResolveOrdinalOnly(raw, selectedMessageAnchors, out anchor, out ordinal); |
| 30 | |
| 31 | public static bool TryFormatPeekOrdinalError(string? raw, int attachmentCount, out string error) |
| 32 | { |
| 33 | error = ""; |
| 34 | if (!AnchorPeekTargetParser.TryParse(raw ?? "", out var target, out _)) |
| 35 | return false; |
| 36 | |
| 37 | if (target.Kind != AnchorPeekTargetKind.Ordinal) |
| 38 | return false; |
| 39 | |
| 40 | if (attachmentCount > 0 && target.Ordinal >= 1 && target.Ordinal <= attachmentCount) |
| 41 | return false; |
| 42 | |
| 43 | error = target.Ordinal < 1 |
| 44 | ? "№ якоря — целое число от 1." |
| 45 | : attachmentCount <= 0 |
| 46 | ? "Выбери сообщение с вложениями: /intercom message select <n>." |
| 47 | : $"Якоря #{target.Ordinal} нет (в сообщении {attachmentCount}). /intercom message anchors list"; |
| 48 | return true; |
| 49 | } |
| 50 | |
| 51 | public static bool TryNormalizeAnchorId(string? raw, out string shortId, out string error) |
| 52 | { |
| 53 | shortId = ""; |
| 54 | error = ""; |
| 55 | if (!AnchorPeekTargetParser.TryParse(raw, out var target, out error)) |
| 56 | return false; |
| 57 | |
| 58 | if (target.Kind != AnchorPeekTargetKind.HexId) |
| 59 | { |
| 60 | error = AnchorPeekTargetParser.InvalidHexError; |
| 61 | return false; |
| 62 | } |
| 63 | |
| 64 | shortId = target.HexId; |
| 65 | return true; |
| 66 | } |
| 67 | |
| 68 | public static string FormatListLine(AttachmentAnchor anchor, int? messageOrdinal = null) |
| 69 | { |
| 70 | var id = string.IsNullOrWhiteSpace(anchor.Id) ? "?" : $"a:{anchor.Id}"; |
| 71 | var label = anchor.DisplayLabel ?? anchor.MemberKey ?? anchor.File ?? "—"; |
| 72 | var outcome = FormatOutcomeShort(anchor.ResolveOutcome); |
| 73 | var loc = FormatLocationShort(anchor); |
| 74 | var ord = messageOrdinal is { } o ? $"#{o} " : ""; |
| 75 | return $"{ord}{id} {label} {outcome} {loc}".TrimEnd(); |
| 76 | } |
| 77 | |
| 78 | public static string FormatOutcomeShort(string? resolveOutcome) |
| 79 | { |
| 80 | var o = resolveOutcome?.Trim(); |
| 81 | if (string.Equals(o, IntercomAttachmentRevealPlan.OutcomeResolved, StringComparison.OrdinalIgnoreCase)) |
| 82 | return "resolved"; |
| 83 | if (string.Equals(o, IntercomAttachmentRevealPlan.OutcomeFileMissing, StringComparison.OrdinalIgnoreCase)) |
| 84 | return "file_missing"; |
| 85 | if (string.Equals(o, IntercomAttachmentRevealPlan.OutcomeMemberNotFound, StringComparison.OrdinalIgnoreCase)) |
| 86 | return "member_not_found"; |
| 87 | if (string.Equals(o, IntercomAttachmentRevealPlan.OutcomeExcerptOnly, StringComparison.OrdinalIgnoreCase)) |
| 88 | return "excerpt_only"; |
| 89 | return string.IsNullOrWhiteSpace(o) ? "—" : o; |
| 90 | } |
| 91 | |
| 92 | private static bool tryResolveOrdinalOnly( |
| 93 | string? raw, |
| 94 | IReadOnlyList<AttachmentAnchor> selectedMessageAnchors, |
| 95 | out AttachmentAnchor anchor, |
| 96 | out int ordinal) |
| 97 | { |
| 98 | anchor = new AttachmentAnchor(); |
| 99 | ordinal = 0; |
| 100 | var context = new AnchorPeekResolveContext( |
| 101 | SelectedMessageIndex: -1, |
| 102 | selectedMessageAnchors, |
| 103 | new Dictionary<string, AttachmentAnchor>(), |
| 104 | []); |
| 105 | |
| 106 | return AnchorPeekResolver.TryResolve(raw, context, out anchor, out _, out ordinal, out _); |
| 107 | } |
| 108 | |
| 109 | private static bool isAnchorPeekHexIdEntryBody(string body) |
| 110 | { |
| 111 | if (string.IsNullOrWhiteSpace(body)) |
| 112 | return false; |
| 113 | |
| 114 | var tokens = body.TrimEnd().Split(' ', StringSplitOptions.RemoveEmptyEntries); |
| 115 | if (tokens.Length == 0) |
| 116 | return false; |
| 117 | |
| 118 | if (tokens[0].Equals("anchor", StringComparison.OrdinalIgnoreCase)) |
| 119 | return isHexArgAfterPeek(tokens, startIndex: 1); |
| 120 | |
| 121 | if (tokens.Length >= 2 |
| 122 | && tokens[0].Equals("intercom", StringComparison.OrdinalIgnoreCase) |
| 123 | && tokens[1].Equals("anchor", StringComparison.OrdinalIgnoreCase)) |
| 124 | { |
| 125 | return isHexArgAfterPeek(tokens, startIndex: 2); |
| 126 | } |
| 127 | |
| 128 | return false; |
| 129 | } |
| 130 | |
| 131 | private static bool isHexArgAfterPeek(string[] tokens, int startIndex) |
| 132 | { |
| 133 | if (tokens.Length <= startIndex) |
| 134 | return false; |
| 135 | |
| 136 | var peekToken = tokens[startIndex]; |
| 137 | if (!peekToken.StartsWith("peek", StringComparison.OrdinalIgnoreCase)) |
| 138 | return false; |
| 139 | |
| 140 | if (peekToken.Equals("peek", StringComparison.OrdinalIgnoreCase)) |
| 141 | { |
| 142 | return tokens.Length > startIndex + 1 |
| 143 | && AnchorPeekTargetParser.LooksLikeHexEntry(tokens[startIndex + 1]); |
| 144 | } |
| 145 | |
| 146 | return peekToken.Length > 4 && AnchorPeekTargetParser.LooksLikeHexEntry(peekToken[4..]); |
| 147 | } |
| 148 | |
| 149 | private static string FormatLocationShort(AttachmentAnchor anchor) |
| 150 | { |
| 151 | if (string.IsNullOrWhiteSpace(anchor.File)) |
| 152 | return "—"; |
| 153 | |
| 154 | var file = anchor.File.Replace('\\', '/'); |
| 155 | if (anchor.LineStart is { } ls && anchor.LineEnd is { } le) |
| 156 | return le == ls ? $"{file} L{ls}" : $"{file} L{ls}–{le}"; |
| 157 | return file; |
| 158 | } |
| 159 | } |
| 160 | |